Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 4ae020af7e0eab736540597711854a6c8ef033f5


Parents : 7efe60e
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-06-19T01:39:42-05:00

feat(security): implement app-wide security settings, including IP allowlist and CSRF protection, along with message blocklist functionality

Changes

45 files changed, 3653 insertions(+), 82 deletions(-)


Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 09c82de7..bff9a743 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -92,6 +92,13 @@ from meshchatx.src.backend.lxmf_sieve import (
normalize_lxmf_sieve_filters,
parse_lxmf_sieve_filters_json,
)
+from meshchatx.src.backend.message_blocklist import (
+ build_export_document as build_blocklist_export_document,
+ first_matching_blocklist_entry,
+ normalize_message_blocklist,
+ parse_import_document,
+ parse_message_blocklist_json,
+)
from meshchatx.src.backend.lxmf_utils import (
FIELD_REACTION,
FIELD_REPLY_QUOTE,
@@ -142,6 +149,29 @@ from meshchatx.src.backend.nomadnet_utils import (
)
from meshchatx.src.backend.page_node_manager import PageNodeManager
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
+from meshchatx.src.backend.app_security_settings import (
+ get_web_ui_ip_allowlist,
+ load_app_security_settings,
+ save_app_security_settings,
+)
+from meshchatx.src.backend.csrf import (
+ ensure_session_csrf_token,
+ rotate_session_csrf_token,
+ validate_csrf_header,
+)
+from meshchatx.src.backend.ip_allowlist import client_ip_allowed
+from meshchatx.src.backend.landlock_sandbox import (
+ apply_landlock_sandbox,
+ landlock_auto_enabled,
+ landlock_disabled_by_env,
+ landlock_kernel_supported,
+ landlock_requested,
+)
+from meshchatx.src.backend.privacy_mode import (
+ OutboundHttpBlockedError,
+ ensure_outbound_http_allowed,
+ privacy_mode_enabled,
+)
from meshchatx.src.backend.recovery import (
CrashRecovery,
HealthMonitor,
@@ -313,6 +343,15 @@ def list_host_network_interfaces():
return out, None
+def _is_loopback_bind_host(host: str | None) -> bool:
+ h = (host or "").strip().lower()
+ return h in ("127.0.0.1", "localhost", "::1", "[::1]")
+
+
+def _csrf_exempt_path(path: str) -> bool:
+ return path == "/api/v1/auth/csrf"
+
+
class ReticulumMeshChat:
DEFAULT_AUTOCONNECT_DISCOVERED_INTERFACES = 3
@@ -371,6 +410,10 @@ class ReticulumMeshChat:
self._rns_loglevel_cli = rns_loglevel
self.websocket_clients: list[web.WebSocketResponse] = []
self._websocket_broadcast_lock = asyncio.Lock()
+ self.listen_host: str | None = None
+ self.listen_port: int | None = None
+ self.use_https: bool = True
+ self.landlock_active: bool = False
# track announce timestamps for rate calculation
self.announce_timestamps = []
@@ -3620,12 +3663,73 @@ class ReticulumMeshChat:
def exit_app(self, code=0):
sys.exit(code)
+ def _require_outbound_http(self, feature: str) -> None:
+ if self.config:
+ ensure_outbound_http_allowed(self.config, feature=feature)
+
+ def _landlock_status_dict(self) -> dict:
+ return {
+ "landlock_kernel_supported": landlock_kernel_supported(),
+ "landlock_requested": landlock_requested(),
+ "landlock_auto_enabled": landlock_auto_enabled(),
+ "landlock_disabled_by_env": landlock_disabled_by_env(),
+ "landlock_active": self.landlock_active,
+ }
+
def get_routes(self):
routes = web.RouteTableDef()
self._define_routes(routes)
return routes
def _define_routes(self, routes):
+ # IP allowlist middleware (app-wide)
+ @web.middleware
+ async def ip_allowlist_middleware(request, handler):
+ path = request.path
+ if path == "/api/v1/status":
+ return await handler(request)
+ allowlist = get_web_ui_ip_allowlist(self.storage_dir)
+ if allowlist:
+ ip = _request_client_ip(request)
+ if not client_ip_allowed(ip, allowlist):
+ if path.startswith("/api/"):
+ return web.json_response(
+ {"error": "Forbidden: client IP not on allowlist"},
+ status=403,
+ )
+ return web.Response(
+ text="Forbidden",
+ status=403,
+ headers={"Content-Type": "text/html"},
+ )
+ return await handler(request)
+
+ # CSRF middleware for cookie-authenticated mutating requests
+ @web.middleware
+ async def csrf_middleware(request, handler):
+ if env_bool("MESHCHAT_DISABLE_CSRF", False):
+ return await handler(request)
+ if request.method in ("GET", "HEAD", "OPTIONS"):
+ return await handler(request)
+ path = request.path
+ if not path.startswith("/api/"):
+ return await handler(request)
+ if _csrf_exempt_path(path):
+ return await handler(request)
+ try:
+ session = await get_session(request)
+ except Exception:
+ return web.json_response(
+ {"error": "Session required for CSRF validation"},
+ status=403,
+ )
+ if not validate_csrf_header(request, session):
+ return web.json_response(
+ {"error": "Invalid or missing CSRF token"},
+ status=403,
+ )
+ return await handler(request)
+
# authentication middleware
@web.middleware
async def auth_middleware(request, handler):
@@ -3670,6 +3774,7 @@ class ReticulumMeshChat:
# allow access to auth endpoints and setup page
public_paths = [
"/api/v1/status",
+ "/api/v1/auth/csrf",
"/api/v1/auth/setup",
"/api/v1/auth/login",
"/api/v1/auth/status",
@@ -4215,9 +4320,70 @@ class ReticulumMeshChat:
return web.json_response(
{
"status": "ok",
+ "listen_host": self.listen_host,
+ "listen_port": self.listen_port,
+ "https_enabled": self.use_https,
+ "is_loopback_bind": _is_loopback_bind_host(self.listen_host),
+ **self._landlock_status_dict(),
+ },
+ )
+
+ @routes.get("/api/v1/server/security")
+ async def server_security_get(request):
+ settings = load_app_security_settings(self.storage_dir)
+ return web.json_response(
+ {
+ "listen_host": self.listen_host,
+ "listen_port": self.listen_port,
+ "https_enabled": self.use_https,
+ "is_loopback_bind": _is_loopback_bind_host(self.listen_host),
+ "web_ui_ip_allowlist": settings.get("web_ui_ip_allowlist", ""),
+ **self._landlock_status_dict(),
+ "privacy_mode_enabled": privacy_mode_enabled(self.config),
+ "auth_enabled": self.auth_enabled,
+ },
+ )
+
+ @routes.patch("/api/v1/server/security")
+ async def server_security_patch(request):
+ try:
+ data = await request.json()
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
+ if not isinstance(data, dict):
+ return web.json_response({"error": "Invalid request body"}, status=400)
+ try:
+ if "web_ui_ip_allowlist" in data:
+ settings = save_app_security_settings(
+ self.storage_dir,
+ {"web_ui_ip_allowlist": data.get("web_ui_ip_allowlist")},
+ )
+ else:
+ settings = load_app_security_settings(self.storage_dir)
+ except ValueError as exc:
+ return web.json_response({"error": str(exc)}, status=400)
+ return web.json_response(
+ {
+ "listen_host": self.listen_host,
+ "listen_port": self.listen_port,
+ "https_enabled": self.use_https,
+ "is_loopback_bind": _is_loopback_bind_host(self.listen_host),
+ "web_ui_ip_allowlist": settings.get("web_ui_ip_allowlist", ""),
+ **self._landlock_status_dict(),
+ "privacy_mode_enabled": privacy_mode_enabled(self.config),
+ "auth_enabled": self.auth_enabled,
},
)
+ @routes.get("/api/v1/auth/csrf")
+ async def auth_csrf(request):
+ try:
+ session = await get_session(request)
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+ token = ensure_session_csrf_token(session)
+ return web.json_response({"csrf_token": token})
+
# auth status
@routes.get("/api/v1/auth/status")
async def auth_status(request):
@@ -4331,6 +4497,7 @@ class ReticulumMeshChat:
session = await get_session(request)
session["authenticated"] = True
session["identity_hash"] = self.identity.hash.hex()
+ rotate_session_csrf_token(session)
if dao:
dao.insert(
@@ -4424,6 +4591,7 @@ class ReticulumMeshChat:
session = await get_session(request)
session["authenticated"] = True
session["identity_hash"] = self.identity.hash.hex()
+ rotate_session_csrf_token(session)
if dao:
dao.insert(
id_hash,
@@ -4507,6 +4675,11 @@ class ReticulumMeshChat:
"User-Agent": "MeshChatX-RNodeFlasher",
}
try:
+ if self.current_context and self.current_context.config:
+ ensure_outbound_http_allowed(
+ self.current_context.config,
+ feature="RNode firmware metadata fetch",
+ )
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
@@ -4521,6 +4694,8 @@ class ReticulumMeshChat:
)
data = await response.json(content_type=None)
return web.json_response(data)
+ except OutboundHttpBlockedError as e:
+ return web.json_response({"error": str(e)}, status=403)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@@ -4547,6 +4722,11 @@ class ReticulumMeshChat:
return web.json_response({"error": "Invalid download URL"}, status=403)
try:
+ if self.current_context and self.current_context.config:
+ ensure_outbound_http_allowed(
+ self.current_context.config,
+ feature="RNode firmware download",
+ )
async with aiohttp.ClientSession() as session:
async with session.get(url, allow_redirects=True) as response:
if response.status != 200:
@@ -4565,6 +4745,8 @@ class ReticulumMeshChat:
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
+ except OutboundHttpBlockedError as e:
+ return web.json_response({"error": str(e)}, status=403)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@@ -4604,6 +4786,11 @@ class ReticulumMeshChat:
gh_headers = {"User-Agent": "MeshChatX-MicronWasmRelease/1.0"}
try:
+ if self.current_context and self.current_context.config:
+ ensure_outbound_http_allowed(
+ self.current_context.config,
+ feature="Micron parser release fetch",
+ )
timeout = aiohttp.ClientTimeout(total=120)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
@@ -4708,12 +4895,19 @@ class ReticulumMeshChat:
)
def do_refresh():
+ if self.config:
+ ensure_outbound_http_allowed(
+ self.config,
+ feature="community interfaces directory fetch",
+ )
return self.community_interfaces_manager.refresh_from_directory(
url=url.strip() if isinstance(url, str) and url.strip() else None,
)
try:
result = await asyncio.to_thread(do_refresh)
+ except OutboundHttpBlockedError as e:
+ return web.json_response({"ok": False, "message": str(e)}, status=403)
except ValueError as e:
return web.json_response({"ok": False, "message": str(e)}, status=400)
except OSError as e:
@@ -11011,6 +11205,11 @@ class ReticulumMeshChat:
async def translator_languages(request):
try:
libretranslate_url = request.query.get("libretranslate_url")
+ if libretranslate_url or (
+ self.translator_handler
+ and self.translator_handler.translator_libretranslate_enabled
+ ):
+ self._require_outbound_http("translator language lookup")
th = self.translator_handler
out = th.get_translator_languages_response(
libretranslate_url=libretranslate_url,
@@ -11027,6 +11226,8 @@ class ReticulumMeshChat:
)
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
+ except OutboundHttpBlockedError as e:
+ return web.json_response({"message": str(e)}, status=403)
except Exception as e:
return web.json_response(
{"message": str(e)},
@@ -11056,6 +11257,8 @@ class ReticulumMeshChat:
)
try:
+ if not use_argos:
+ self._require_outbound_http("LibreTranslate")
result = self.translator_handler.translate_text(
text=text,
source_lang=source_lang,
@@ -11067,6 +11270,8 @@ class ReticulumMeshChat:
return web.json_response(result)
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
+ except OutboundHttpBlockedError as e:
+ return web.json_response({"message": str(e)}, status=403)
except Exception as e:
return web.json_response(
{"message": str(e)},
@@ -11079,6 +11284,7 @@ class ReticulumMeshChat:
package_name = data.get("package", "translate")
try:
+ self._require_outbound_http("Argos language package install")
result = self.translator_handler.install_language_package(package_name)
return web.json_response(result)
except Exception as e:
@@ -12179,6 +12385,75 @@ class ReticulumMeshChat:
self.config.lxmf_sieve_filters_json.set(json.dumps(normalized))
return web.json_response({"filters": normalized})
+ @routes.get("/api/v1/lxmf/message-blocklist")
+ async def lxmf_message_blocklist_get(request):
+ raw = self.config.message_blocklist_json.get()
+ return web.json_response(
+ {
+ "enabled": self.config.message_blocklist_enabled.get(),
+ "blocklist": parse_message_blocklist_json(raw),
+ },
+ )
+
+ @routes.put("/api/v1/lxmf/message-blocklist")
+ async def lxmf_message_blocklist_put(request):
+ data = await request.json()
+ blocklist_in = data.get("blocklist")
+ if not isinstance(blocklist_in, dict):
+ return web.json_response(
+ {"message": "blocklist must be an object"},
+ status=400,
+ )
+ normalized = normalize_message_blocklist(blocklist_in)
+ if "enabled" in data:
+ self.config.message_blocklist_enabled.set(
+ self._parse_bool(data["enabled"]),
+ )
+ self.config.message_blocklist_json.set(json.dumps(normalized))
+ return web.json_response(
+ {
+ "enabled": self.config.message_blocklist_enabled.get(),
+ "blocklist": normalized,
+ },
+ )
+
+ @routes.get("/api/v1/lxmf/message-blocklist/export")
+ async def lxmf_message_blocklist_export(request):
+ raw = self.config.message_blocklist_json.get()
+ blocklist = parse_message_blocklist_json(raw)
+ return web.json_response(build_blocklist_export_document(blocklist))
+
+ @routes.post("/api/v1/lxmf/message-blocklist/import")
+ async def lxmf_message_blocklist_import(request):
+ data = await request.json()
+ document = data.get("document")
+ if not isinstance(document, dict):
+ return web.json_response(
+ {"message": "document must be an object"},
+ status=400,
+ )
+ merge = self._parse_bool(data.get("merge", False))
+ existing = parse_message_blocklist_json(
+ self.config.message_blocklist_json.get(),
+ )
+ imported = parse_import_document(
+ document,
+ merge=merge,
+ existing=existing,
+ )
+ if imported is None:
+ return web.json_response(
+ {"message": "Invalid blocklist document"},
+ status=400,
+ )
+ self.config.message_blocklist_json.set(json.dumps(imported))
+ return web.json_response(
+ {
+ "enabled": self.config.message_blocklist_enabled.get(),
+ "blocklist": imported,
+ },
+ )
+
@routes.post("/api/v1/lxmf/conversations/move-to-folder")
async def lxmf_conversations_move_to_folder(request):
data = await request.json()
@@ -13553,6 +13828,8 @@ class ReticulumMeshChat:
if not bbox or len(bbox) != 4:
return web.json_response({"error": "Invalid bbox"}, status=400)
+ self._require_outbound_http("map tile export")
+
tile_count = self.map_manager.count_export_tiles(
bbox,
min_zoom,
@@ -13574,6 +13851,8 @@ class ReticulumMeshChat:
self.map_manager.start_export(export_id, bbox, min_zoom, max_zoom, name)
return web.json_response({"export_id": export_id})
+ except OutboundHttpBlockedError as e:
+ return web.json_response({"error": str(e)}, status=403)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@@ -13669,29 +13948,38 @@ class ReticulumMeshChat:
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# CSP base configuration
+ privacy_mode = privacy_mode_enabled(self.config)
connect_sources = [
"'self'",
"ws://localhost:*",
"wss://localhost:*",
"blob:",
- "https://*.tile.openstreetmap.org",
- "https://tile.openstreetmap.org",
- "https://nominatim.openstreetmap.org",
- "https://*.cartocdn.com",
- "https://tiles.openfreemap.org",
- "https://*.openfreemap.org",
]
-
img_sources = [
"'self'",
"data:",
"blob:",
- "https://*.tile.openstreetmap.org",
- "https://tile.openstreetmap.org",
- "https://*.cartocdn.com",
- "https://tiles.openfreemap.org",
- "https://*.openfreemap.org",
]
+ if not privacy_mode:
+ connect_sources.extend(
+ [
+ "https://*.tile.openstreetmap.org",
+ "https://tile.openstreetmap.org",
+ "https://nominatim.openstreetmap.org",
+ "https://*.cartocdn.com",
+ "https://tiles.openfreemap.org",
+ "https://*.openfreemap.org",
+ ],
+ )
+ img_sources.extend(
+ [
+ "https://*.tile.openstreetmap.org",
+ "https://tile.openstreetmap.org",
+ "https://*.cartocdn.com",
+ "https://tiles.openfreemap.org",
+ "https://*.openfreemap.org",
+ ],
+ )
frame_sources = [
"'self'",
@@ -13724,7 +14012,11 @@ class ReticulumMeshChat:
script_sources = ["'self'", "'wasm-unsafe-eval'", "blob:"]
style_sources = ["'self'", "'unsafe-inline'"]
- if self.current_context and self.current_context.config:
+ if (
+ self.current_context
+ and self.current_context.config
+ and not privacy_mode
+ ):
# Helper to add domain from URL
def add_domain_from_url(url, target_list):
if not url:
@@ -13796,8 +14088,12 @@ class ReticulumMeshChat:
f"script-src {' '.join(script_sources)}; "
f"style-src {' '.join(style_sources)}; "
f"img-src {' '.join(img_sources)}; "
- "font-src 'self' data: https://tiles.openfreemap.org https://*.openfreemap.org; "
- f"connect-src {' '.join(connect_sources)}; "
+ + (
+ "font-src 'self' data:; "
+ if privacy_mode
+ else "font-src 'self' data: https://tiles.openfreemap.org https://*.openfreemap.org; "
+ )
+ + f"connect-src {' '.join(connect_sources)}; "
"media-src 'self' blob:; "
"worker-src 'self' blob:; "
f"frame-src {' '.join(frame_sources)}; "
@@ -13807,7 +14103,13 @@ class ReticulumMeshChat:
response.headers["Content-Security-Policy"] = csp
return response
- return auth_middleware, mime_type_middleware, security_middleware
+ return (
+ auth_middleware,
+ mime_type_middleware,
+ security_middleware,
+ csrf_middleware,
+ ip_allowlist_middleware,
+ )
def _encrypted_cookie_storage(self, use_https: bool) -> EncryptedCookieStorage:
try:
@@ -13902,12 +14204,19 @@ class ReticulumMeshChat:
def run(self, host, port, launch_browser: bool, enable_https: bool = True):
# create route table
routes = web.RouteTableDef()
- auth_middleware, mime_type_middleware, security_middleware = (
- self._define_routes(routes)
- )
+ (
+ auth_middleware,
+ mime_type_middleware,
+ security_middleware,
+ csrf_middleware,
+ ip_allowlist_middleware,
+ ) = self._define_routes(routes)
ssl_context = None
use_https = enable_https
+ self.listen_host = host
+ self.listen_port = port
+ self.use_https = use_https
if enable_https:
custom_ssl = bool(self.ssl_cert_path and self.ssl_key_path)
if custom_ssl:
@@ -13996,7 +14305,13 @@ class ReticulumMeshChat:
# add other middlewares
app.middlewares.extend(
- [auth_middleware, mime_type_middleware, security_middleware],
+ [
+ auth_middleware,
+ mime_type_middleware,
+ security_middleware,
+ csrf_middleware,
+ ip_allowlist_middleware,
+ ],
)
app.add_routes(routes)
@@ -14531,6 +14846,11 @@ class ReticulumMeshChat:
if not value:
self.config.auth_password_hash.set(None)
+ if "privacy_mode_enabled" in data:
+ self.config.privacy_mode_enabled.set(
+ self._parse_bool(data["privacy_mode_enabled"]),
+ )
+
# update map settings
if "map_offline_enabled" in data:
self.config.map_offline_enabled.set(
@@ -14634,6 +14954,10 @@ class ReticulumMeshChat:
self.config.local_message_auto_delete_enabled.set(
self._parse_bool(data["local_message_auto_delete_enabled"]),
)
+ if "message_blocklist_enabled" in data:
+ self.config.message_blocklist_enabled.set(
+ self._parse_bool(data["message_blocklist_enabled"]),
+ )
if (
"local_message_auto_delete_value" in data
or "local_message_auto_delete_unit" in data
@@ -16139,6 +16463,7 @@ class ReticulumMeshChat:
"crawler_retry_delay_seconds": ctx.config.crawler_retry_delay_seconds.get(),
"crawler_max_concurrent": ctx.config.crawler_max_concurrent.get(),
"auth_enabled": self.auth_enabled,
+ "privacy_mode_enabled": ctx.config.privacy_mode_enabled.get(),
"voicemail_enabled": ctx.config.voicemail_enabled.get(),
"voicemail_greeting": ctx.config.voicemail_greeting.get(),
"voicemail_auto_answer_delay_seconds": ctx.config.voicemail_auto_answer_delay_seconds.get(),
@@ -16228,6 +16553,7 @@ class ReticulumMeshChat:
"local_message_auto_delete_value": ctx.config.local_message_auto_delete_value.get(),
"local_message_auto_delete_unit": ctx.config.local_message_auto_delete_unit.get()
or "days",
+ "message_blocklist_enabled": ctx.config.message_blocklist_enabled.get(),
}
# try and get a name for the provided identity hash
@@ -17124,6 +17450,45 @@ class ReticulumMeshChat:
return
self.banish_lxmf_peer(peer_hash, context=context)
+ def _apply_message_blocklist_banish_rule(
+ self,
+ peer_hash: str,
+ context=None,
+ *,
+ message_title=None,
+ message_content=None,
+ ):
+ ctx = context or self.current_context
+ if not ctx or not ctx.config:
+ return
+ if not ctx.config.message_blocklist_enabled.get():
+ return
+ raw = ctx.config.message_blocklist_json.get()
+ blocklist = parse_message_blocklist_json(raw)
+ contact = None
+ is_contact = False
+ if ctx.database:
+ contact = ctx.database.contacts.get_contact_by_identity_hash(peer_hash)
+ is_contact = bool(contact)
+ haystack = self._collect_lxmf_sieve_peer_haystack(
+ peer_hash,
+ context=ctx,
+ contact=contact,
+ )
+ msg_hs = self._lxmf_sieve_message_haystack(message_title, message_content)
+ match = first_matching_blocklist_entry(
+ blocklist,
+ haystack,
+ is_contact=is_contact,
+ message_haystack=msg_hs,
+ )
+ if not match:
+ return
+ print(
+ f"Message blocklist matched entry {match.get('entry_id')} for {peer_hash}; banishing",
+ )
+ self.banish_lxmf_peer(peer_hash, context=ctx)
+
def on_lxmf_delivery(self, lxmf_message: LXMF.LXMessage, context=None):
"""Handle inbound LXMF delivery from Reticulum (synchronous callback)."""
ctx = context or self.current_context
@@ -17325,6 +17690,12 @@ class ReticulumMeshChat:
message_title=message_title,
message_content=message_content,
)
+ self._apply_message_blocklist_banish_rule(
+ source_hash,
+ context=ctx,
+ message_title=message_title,
+ message_content=message_content,
+ )
# handle telemetry
try:
@@ -19198,6 +19569,12 @@ def main():
print(f"Error: Snapshot not found at {snapshot_path}")
enable_https = not args.no_https
+ reticulum_meshchat.landlock_active = apply_landlock_sandbox(
+ storage_dir=reticulum_meshchat.storage_dir,
+ reticulum_config_dir=reticulum_meshchat.reticulum_config_dir,
+ public_dir=reticulum_meshchat.public_dir_override or get_file_path("public"),
+ log_dir=resolve_log_dir(),
+ )
reticulum_meshchat.run(
args.host,
args.port,

diff --git a/meshchatx/src/backend/app_security_settings.py b/meshchatx/src/backend/app_security_settings.py
new file mode 100644
index 00000000..42901a44
--- /dev/null
+++ b/meshchatx/src/backend/app_security_settings.py
@@ -0,0 +1,68 @@
+# SPDX-License-Identifier: 0BSD
+
+"""App-wide security settings persisted under the storage directory."""
+
+from __future__ import annotations
+
+import json
+import os
+import threading
+from typing import Any
+
+from meshchatx.src.backend.ip_allowlist import normalize_allowlist_text
+
+_SETTINGS_FILENAME = "app_security.json"
+_LOCK = threading.RLock()
+
+
+def _settings_path(storage_dir: str) -> str:
+ return os.path.join(storage_dir, _SETTINGS_FILENAME)
+
+
+def _default_settings() -> dict[str, Any]:
+ return {
+ "web_ui_ip_allowlist": "",
+ }
+
+
+def load_app_security_settings(storage_dir: str) -> dict[str, Any]:
+ path = _settings_path(storage_dir)
+ with _LOCK:
+ if not os.path.isfile(path):
+ return _default_settings()
+ try:
+ with open(path, encoding="utf-8") as f:
+ data = json.load(f)
+ except (OSError, json.JSONDecodeError):
+ return _default_settings()
+ if not isinstance(data, dict):
+ return _default_settings()
+ merged = _default_settings()
+ merged.update(data)
+ return merged
+
+
+def save_app_security_settings(
+ storage_dir: str, updates: dict[str, Any]
+) -> dict[str, Any]:
+ from meshchatx.src.backend.ip_allowlist import parse_allowlist_networks
+
+ current = load_app_security_settings(storage_dir)
+ if "web_ui_ip_allowlist" in updates:
+ text = normalize_allowlist_text(updates.get("web_ui_ip_allowlist"))
+ if text:
+ parse_allowlist_networks(text)
+ current["web_ui_ip_allowlist"] = text
+ path = _settings_path(storage_dir)
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
+ with _LOCK:
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(current, f, indent=2)
+ f.write("\n")
+ return current
+
+
+def get_web_ui_ip_allowlist(storage_dir: str) -> str:
+ return normalize_allowlist_text(
+ load_app_security_settings(storage_dir).get("web_ui_ip_allowlist"),
+ )

diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index c4fcd96b..e58be9ae 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -158,6 +158,7 @@ class ConfigManager:
self.auth_enabled = self.BoolConfig(self, "auth_enabled", False)
self.auth_password_hash = self.StringConfig(self, "auth_password_hash", None)
self.auth_session_secret = self.StringConfig(self, "auth_session_secret", None)
+ self.privacy_mode_enabled = self.BoolConfig(self, "privacy_mode_enabled", False)
self.gitea_base_url = self.StringConfig(
self,
"gitea_base_url",
@@ -520,6 +521,16 @@ class ConfigManager:
"lxmf_sieve_filters_json",
"[]",
)
+ self.message_blocklist_enabled = self.BoolConfig(
+ self,
+ "message_blocklist_enabled",
+ False,
+ )
+ self.message_blocklist_json = self.StringConfig(
+ self,
+ "message_blocklist_json",
+ '{"scope":"non_contacts","match_peer_fields":false,"match_message":true,"entries":[]}',
+ )
self.local_message_auto_delete_enabled = self.BoolConfig(
self,

diff --git a/meshchatx/src/backend/csrf.py b/meshchatx/src/backend/csrf.py
new file mode 100644
index 00000000..3cb45758
--- /dev/null
+++ b/meshchatx/src/backend/csrf.py
@@ -0,0 +1,38 @@
+# SPDX-License-Identifier: 0BSD
+
+"""CSRF token helpers for cookie-authenticated API requests."""
+
+from __future__ import annotations
+
+import secrets
+
+CSRF_HEADER = "X-CSRF-Token"
+CSRF_SESSION_KEY = "csrf_token"
+
+
+def new_csrf_token() -> str:
+ return secrets.token_urlsafe(32)
+
+
+def ensure_session_csrf_token(session) -> str:
+ token = session.get(CSRF_SESSION_KEY)
+ if not token or not isinstance(token, str):
+ token = new_csrf_token()
+ session[CSRF_SESSION_KEY] = token
+ return token
+
+
+def rotate_session_csrf_token(session) -> str:
+ token = new_csrf_token()
+ session[CSRF_SESSION_KEY] = token
+ return token
+
+
+def validate_csrf_header(request, session) -> bool:
+ expected = session.get(CSRF_SESSION_KEY)
+ if not expected or not isinstance(expected, str):
+ return False
+ provided = request.headers.get(CSRF_HEADER, "")
+ if not provided or not isinstance(provided, str):
+ return False
+ return secrets.compare_digest(provided.strip(), expected.strip())

diff --git a/meshchatx/src/backend/ip_allowlist.py b/meshchatx/src/backend/ip_allowlist.py
new file mode 100644
index 00000000..c9cc8921
--- /dev/null
+++ b/meshchatx/src/backend/ip_allowlist.py
@@ -0,0 +1,55 @@
+# SPDX-License-Identifier: 0BSD
+
+"""IP/CIDR allowlist for web UI access."""
+
+from __future__ import annotations
+
+import ipaddress
+import re
+
+
+def normalize_allowlist_text(value: str | None) -> str:
+ if value is None:
+ return ""
+ return str(value).strip()
+
+
+def parse_allowlist_entries(text: str | None) -> list[str]:
+ raw = normalize_allowlist_text(text)
+ if not raw:
+ return []
+ parts = re.split(r"[\s,;]+", raw)
+ return [p.strip() for p in parts if p.strip()]
+
+
+def parse_allowlist_networks(text: str | None) -> list[ipaddress._BaseNetwork]:
+ entries = parse_allowlist_entries(text)
+ networks: list[ipaddress._BaseNetwork] = []
+ for entry in entries:
+ try:
+ if "/" in entry:
+ networks.append(ipaddress.ip_network(entry, strict=False))
+ else:
+ addr = ipaddress.ip_address(entry)
+ width = addr.max_prefixlen
+ networks.append(ipaddress.ip_network(f"{addr}/{width}", strict=False))
+ except ValueError as exc:
+ msg = f"Invalid allowlist entry: {entry!r}"
+ raise ValueError(msg) from exc
+ return networks
+
+
+def client_ip_allowed(client_ip: str, allowlist_text: str | None) -> bool:
+ entries = parse_allowlist_entries(allowlist_text)
+ if not entries:
+ return True
+ if not client_ip:
+ return False
+ try:
+ addr = ipaddress.ip_address(client_ip.strip())
+ except ValueError:
+ return False
+ for network in parse_allowlist_networks(allowlist_text):
+ if addr in network:
+ return True
+ return False

diff --git a/meshchatx/src/backend/landlock_sandbox.py b/meshchatx/src/backend/landlock_sandbox.py
new file mode 100644
index 00000000..bbef9fb8
--- /dev/null
+++ b/meshchatx/src/backend/landlock_sandbox.py
@@ -0,0 +1,359 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Optional Landlock LSM filesystem sandbox for the backend (Linux only)."""
+
+from __future__ import annotations
+
+import ctypes
+import ctypes.util
+import errno
+import logging
+import os
+import site
+import sys
+import tempfile
+
+logger = logging.getLogger("meshchatx.landlock")
+
+_LANDLOCK_ACCESS_FS_EXECUTE = 1 << 0
+_LANDLOCK_ACCESS_FS_WRITE_FILE = 1 << 1
+_LANDLOCK_ACCESS_FS_READ_FILE = 1 << 2
+_LANDLOCK_ACCESS_FS_READ_DIR = 1 << 3
+_LANDLOCK_ACCESS_FS_REMOVE_DIR = 1 << 4
+_LANDLOCK_ACCESS_FS_REMOVE_FILE = 1 << 5
+_LANDLOCK_ACCESS_FS_MAKE_CHAR = 1 << 6
+_LANDLOCK_ACCESS_FS_MAKE_DIR = 1 << 7
+_LANDLOCK_ACCESS_FS_MAKE_REG = 1 << 8
+_LANDLOCK_ACCESS_FS_MAKE_SOCK = 1 << 9
+_LANDLOCK_ACCESS_FS_MAKE_FIFO = 1 << 10
+_LANDLOCK_ACCESS_FS_MAKE_BLOCK = 1 << 11
+_LANDLOCK_ACCESS_FS_MAKE_SYM = 1 << 12
+
+_LANDLOCK_CREATE_RULESET_VERSION = 1 << 0
+_LANDLOCK_RULE_PATH_BENEATH = 1
+
+_PR_SET_NO_NEW_PRIVS = 38
+
+_READ_ACCESS = (
+ _LANDLOCK_ACCESS_FS_READ_FILE
+ | _LANDLOCK_ACCESS_FS_READ_DIR
+ | _LANDLOCK_ACCESS_FS_EXECUTE
+)
+_RW_ACCESS = _READ_ACCESS | (
+ _LANDLOCK_ACCESS_FS_WRITE_FILE
+ | _LANDLOCK_ACCESS_FS_REMOVE_DIR
+ | _LANDLOCK_ACCESS_FS_REMOVE_FILE
+ | _LANDLOCK_ACCESS_FS_MAKE_CHAR
+ | _LANDLOCK_ACCESS_FS_MAKE_DIR
+ | _LANDLOCK_ACCESS_FS_MAKE_REG
+ | _LANDLOCK_ACCESS_FS_MAKE_SOCK
+ | _LANDLOCK_ACCESS_FS_MAKE_FIFO
+ | _LANDLOCK_ACCESS_FS_MAKE_BLOCK
+ | _LANDLOCK_ACCESS_FS_MAKE_SYM
+)
+
+_SYSCALL_NUMBERS = {
+ "x86_64": (444, 445, 446),
+ "aarch64": (444, 445, 446),
+ "arm": (383, 384, 385),
+ "riscv64": (444, 445, 446),
+}
+
+
+class _LandlockRulesetAttr(ctypes.Structure):
+ _fields_ = [
+ ("handled_access_fs", ctypes.c_uint64),
+ ("handled_access_net", ctypes.c_uint64),
+ ("scoped", ctypes.c_uint64),
+ ]
+
+
+class _LandlockPathBeneathAttr(ctypes.Structure):
+ _fields_ = [
+ ("allowed_access", ctypes.c_uint64),
+ ("parent_fd", ctypes.c_int32),
+ ]
+ _pack_ = 1
+
+
+def _parse_kernel_version(release: str) -> tuple[int, int, int]:
+ base = (release or "").split("-", 1)[0]
+ parts = base.split(".")
+ nums: list[int] = []
+ for part in parts[:3]:
+ digits = ""
+ for ch in part:
+ if ch.isdigit():
+ digits += ch
+ else:
+ break
+ nums.append(int(digits) if digits else 0)
+ while len(nums) < 3:
+ nums.append(0)
+ return nums[0], nums[1], nums[2]
+
+
+def _kernel_version_meets_minimum(min_major: int = 5, min_minor: int = 13) -> bool:
+ try:
+ major, minor, _patch = _parse_kernel_version(os.uname().release)
+ except (AttributeError, OSError, ValueError):
+ return False
+ if major > min_major:
+ return True
+ if major == min_major:
+ return minor >= min_minor
+ return False
+
+
+def _landlock_env_override() -> bool | None:
+ raw = os.environ.get("MESHCHAT_LANDLOCK")
+ if raw is None:
+ return None
+ val = raw.strip().lower()
+ if val in ("false", "0", "no", "off"):
+ return False
+ if val in ("true", "1", "yes", "on"):
+ return True
+ return None
+
+
+_landlock_support_cached: bool | None = None
+
+
+def _syscall_numbers():
+ machine = os.uname().machine.lower()
+ if machine in _SYSCALL_NUMBERS:
+ return _SYSCALL_NUMBERS[machine]
+ return _SYSCALL_NUMBERS.get("x86_64")
+
+
+def _libc():
+ name = ctypes.util.find_library("c")
+ if not name:
+ return None
+ libc = ctypes.CDLL(name, use_errno=True)
+ libc.syscall.restype = ctypes.c_long
+ return libc
+
+
+def _syscall(libc, nr: int, *args):
+ rc = libc.syscall(nr, *args)
+ if rc < 0:
+ err = ctypes.get_errno()
+ msg = f"landlock syscall {nr} failed: errno {err}"
+ raise OSError(err, os.strerror(err), msg)
+ return rc
+
+
+def _probe_landlock_create_ruleset() -> bool:
+ libc = _libc()
+ nums = _syscall_numbers()
+ if libc is None or nums is None:
+ return False
+ create_nr, _, _ = nums
+ try:
+ abi = _syscall(libc, create_nr, 0, 0, _LANDLOCK_CREATE_RULESET_VERSION)
+ except OSError as exc:
+ if exc.errno in (errno.ENOSYS, errno.EOPNOTSUPP):
+ return False
+ return False
+ return abi >= 1
+
+
+def landlock_kernel_supported() -> bool:
+ global _landlock_support_cached
+ if _landlock_support_cached is not None:
+ return _landlock_support_cached
+ if sys.platform != "linux":
+ _landlock_support_cached = False
+ return False
+ if not _kernel_version_meets_minimum():
+ _landlock_support_cached = False
+ return False
+ _landlock_support_cached = _probe_landlock_create_ruleset()
+ return _landlock_support_cached
+
+
+def landlock_requested() -> bool:
+ if sys.platform != "linux":
+ return False
+ override = _landlock_env_override()
+ if override is False:
+ return False
+ if override is True:
+ return True
+ return landlock_kernel_supported()
+
+
+def landlock_auto_enabled() -> bool:
+ return landlock_requested() and _landlock_env_override() is None
+
+
+def landlock_disabled_by_env() -> bool:
+ return _landlock_env_override() is False
+
+
+def _set_no_new_privs(libc) -> None:
+ rc = libc.prctl(_PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
+ if rc != 0:
+ err = ctypes.get_errno()
+ msg = f"prctl(PR_SET_NO_NEW_PRIVS) failed: errno {err}"
+ raise OSError(msg)
+
+
+def _existing_dir(path: str | None) -> str | None:
+ if not path:
+ return None
+ resolved = os.path.abspath(os.path.expanduser(path))
+ if os.path.isdir(resolved):
+ return resolved
+ parent = os.path.dirname(resolved)
+ if parent and os.path.isdir(parent):
+ return parent
+ return None
+
+
+def _collect_read_roots() -> list[str]:
+ roots = {
+ "/usr",
+ "/lib",
+ "/lib64",
+ "/etc",
+ "/bin",
+ "/sbin",
+ }
+ for path in sys.path:
+ existing = _existing_dir(path)
+ if existing:
+ roots.add(existing)
+ for path in site.getsitepackages():
+ existing = _existing_dir(path)
+ if existing:
+ roots.add(existing)
+ user_site = site.getusersitepackages()
+ existing = _existing_dir(user_site)
+ if existing:
+ roots.add(existing)
+ return sorted(roots)
+
+
+def _collect_rw_roots(
+ storage_dir: str | None,
+ reticulum_config_dir: str | None,
+ log_dir: str | None,
+) -> list[str]:
+ paths: list[str] = []
+ for candidate in (
+ storage_dir,
+ reticulum_config_dir,
+ log_dir,
+ tempfile.gettempdir(),
+ "/dev/shm",
+ "/run",
+ ):
+ existing = _existing_dir(candidate)
+ if existing and existing not in paths:
+ paths.append(existing)
+ if os.path.isdir("/dev"):
+ paths.append("/dev")
+ return paths
+
+
+def _add_path_beneath_rule(
+ libc,
+ add_rule_nr: int,
+ ruleset_fd: int,
+ path: str,
+ access: int,
+) -> None:
+ if not path or not os.path.exists(path):
+ return
+ effective_access = access
+ if not os.path.isdir(path):
+ effective_access = (
+ _LANDLOCK_ACCESS_FS_READ_FILE | _LANDLOCK_ACCESS_FS_WRITE_FILE
+ )
+ open_flags = os.O_PATH | os.O_CLOEXEC | os.O_RDONLY
+ try:
+ fd = os.open(path, open_flags)
+ except OSError:
+ return
+ try:
+ attr = _LandlockPathBeneathAttr(allowed_access=effective_access, parent_fd=fd)
+ _syscall(
+ libc,
+ add_rule_nr,
+ ruleset_fd,
+ _LANDLOCK_RULE_PATH_BENEATH,
+ ctypes.byref(attr),
+ 0,
+ )
+ finally:
+ os.close(fd)
+
+
+def apply_landlock_sandbox(
+ *,
+ storage_dir: str | None = None,
+ reticulum_config_dir: str | None = None,
+ public_dir: str | None = None,
+ log_dir: str | None = None,
+) -> bool:
+ """Apply Landlock rules. Returns True when the sandbox is active."""
+ if not landlock_requested():
+ return False
+
+ libc = _libc()
+ nums = _syscall_numbers()
+ if libc is None or nums is None:
+ logger.warning("Landlock requested but libc or syscall numbers are unavailable")
+ return False
+
+ create_nr, add_rule_nr, restrict_nr = nums
+ try:
+ _set_no_new_privs(libc)
+ except OSError as exc:
+ logger.warning("Landlock disabled: %s", exc)
+ return False
+
+ attr = _LandlockRulesetAttr(handled_access_fs=_RW_ACCESS)
+ try:
+ ruleset_fd = _syscall(
+ libc,
+ create_nr,
+ ctypes.byref(attr),
+ ctypes.sizeof(attr),
+ 0,
+ )
+ except OSError as exc:
+ logger.warning("Landlock disabled: %s", exc)
+ return False
+
+ try:
+ for root in _collect_read_roots():
+ _add_path_beneath_rule(libc, add_rule_nr, ruleset_fd, root, _READ_ACCESS)
+ rw_roots = _collect_rw_roots(storage_dir, reticulum_config_dir, log_dir)
+ public_existing = _existing_dir(public_dir)
+ if public_existing and public_existing not in rw_roots:
+ rw_roots.append(public_existing)
+ for root in rw_roots:
+ _add_path_beneath_rule(libc, add_rule_nr, ruleset_fd, root, _RW_ACCESS)
+ _syscall(libc, restrict_nr, ruleset_fd, 0)
+ except OSError as exc:
+ logger.warning("Landlock disabled while adding rules: %s", exc)
+ try:
+ os.close(ruleset_fd)
+ except OSError:
+ pass
+ return False
+
+ try:
+ os.close(ruleset_fd)
+ except OSError:
+ pass
+
+ if landlock_auto_enabled():
+ logger.info("Landlock filesystem sandbox enabled (auto-detected on Linux)")
+ else:
+ logger.info("Landlock filesystem sandbox enabled")
+ return True

diff --git a/meshchatx/src/backend/message_blocklist.py b/meshchatx/src/backend/message_blocklist.py
new file mode 100644
index 00000000..474608aa
--- /dev/null
+++ b/meshchatx/src/backend/message_blocklist.py
@@ -0,0 +1,232 @@
+# SPDX-License-Identifier: 0BSD
+
+"""LXMF message blocklist: match spam phrases (substring or regex) and auto-banish."""
+
+from __future__ import annotations
+
+import json
+import re
+import uuid
+from typing import Any
+
+from meshchatx.src.backend.lxmf_sieve import (
+ _any_term_matches_regex,
+ _any_term_matches_substring,
+ _rule_scope_matches,
+)
+
+MAX_ENTRIES = 256
+MAX_TERM_LEN = 512
+BLOCKLIST_SCOPES = frozenset({"everyone", "contacts", "non_contacts"})
+MATCH_MODES = frozenset({"substring", "regex"})
+EXPORT_SCHEMA = "meshchatx.message_blocklist"
+EXPORT_VERSION = 1
+
+_REGEX_FLAGS = re.IGNORECASE | re.DOTALL
+
+
+def _new_entry_id() -> str:
+ return uuid.uuid4().hex[:16]
+
+
+def _validate_regex_pattern(pattern: str) -> str | None:
+ p = str(pattern)[:MAX_TERM_LEN]
+ if not p.strip():
+ return None
+ try:
+ re.compile(p, _REGEX_FLAGS)
+ except re.error:
+ return None
+ return p
+
+
+def _normalize_scope(raw: str | None) -> str:
+ if raw in BLOCKLIST_SCOPES:
+ return raw
+ return "non_contacts"
+
+
+def _normalize_entry(item: dict[str, Any]) -> dict[str, Any] | None:
+ text_in = item.get("text") or item.get("term") or item.get("pattern") or ""
+ text = str(text_in).strip()[:MAX_TERM_LEN]
+ if not text:
+ return None
+
+ match_mode = item.get("match_mode") or "substring"
+ if match_mode not in MATCH_MODES:
+ match_mode = "substring"
+
+ if match_mode == "regex":
+ validated = _validate_regex_pattern(text)
+ if validated is None:
+ return None
+ text = validated
+
+ rid = str(item.get("id") or "").strip() or _new_entry_id()
+ return {
+ "id": rid,
+ "enabled": bool(item.get("enabled", True)),
+ "text": text,
+ "match_mode": match_mode,
+ }
+
+
+def normalize_message_blocklist(data: dict[str, Any] | None) -> dict[str, Any]:
+ """Validate and normalize blocklist settings from user input."""
+ if not isinstance(data, dict):
+ data = {}
+
+ scope = _normalize_scope(data.get("scope"))
+ match_peer_fields = bool(data.get("match_peer_fields", False))
+ match_message = data.get("match_message")
+ if match_message is None:
+ match_message = True
+ else:
+ match_message = bool(match_message)
+ if not match_peer_fields and not match_message:
+ match_message = True
+
+ entries_in = data.get("entries")
+ if not isinstance(entries_in, list):
+ entries_in = []
+
+ entries: list[dict[str, Any]] = []
+ for item in entries_in[:MAX_ENTRIES]:
+ if not isinstance(item, dict):
+ continue
+ normalized = _normalize_entry(item)
+ if normalized is not None:
+ entries.append(normalized)
+
+ return {
+ "scope": scope,
+ "match_peer_fields": match_peer_fields,
+ "match_message": match_message,
+ "entries": entries,
+ }
+
+
+def parse_message_blocklist_json(raw: str | None) -> dict[str, Any]:
+ if not raw or not str(raw).strip():
+ return normalize_message_blocklist({})
+ try:
+ data = json.loads(raw)
+ except (json.JSONDecodeError, TypeError):
+ return normalize_message_blocklist({})
+ if not isinstance(data, dict):
+ return normalize_message_blocklist({})
+ return normalize_message_blocklist(data)
+
+
+def _entry_matches(entry: dict[str, Any], text_lower: str, text_raw: str) -> bool:
+ pattern = entry.get("text") or ""
+ mode = entry.get("match_mode") or "substring"
+ if mode == "regex":
+ return _any_term_matches_regex([pattern], text_raw)
+ return _any_term_matches_substring([pattern], text_lower)
+
+
+def first_matching_blocklist_entry(
+ blocklist: dict[str, Any],
+ peer_haystack: str | None,
+ *,
+ is_contact: bool = False,
+ message_haystack: str | None = None,
+) -> dict[str, Any] | None:
+ """Return the first enabled entry whose scope and targets match."""
+ if not isinstance(blocklist, dict):
+ return None
+ if not _rule_scope_matches(blocklist, is_contact):
+ return None
+
+ peer_raw = peer_haystack or ""
+ peer_lower = peer_raw.lower()
+ msg_raw = message_haystack
+ msg_lower = (message_haystack or "").lower()
+
+ peer_fields = bool(blocklist.get("match_peer_fields", False))
+ match_msg = bool(blocklist.get("match_message", True))
+
+ for entry in blocklist.get("entries") or []:
+ if not entry.get("enabled", True):
+ continue
+
+ peer_ok = True
+ if peer_fields:
+ peer_ok = _entry_matches(entry, peer_lower, peer_raw)
+
+ msg_ok = True
+ if match_msg:
+ if msg_raw is None:
+ continue
+ msg_ok = _entry_matches(entry, msg_lower, msg_raw)
+
+ if peer_ok and msg_ok:
+ return {
+ "entry_id": entry.get("id"),
+ "text": entry.get("text"),
+ "match_mode": entry.get("match_mode"),
+ }
+ return None
+
+
+def build_export_document(blocklist: dict[str, Any]) -> dict[str, Any]:
+ normalized = normalize_message_blocklist(blocklist)
+ return {
+ "schema": EXPORT_SCHEMA,
+ "version": EXPORT_VERSION,
+ "scope": normalized["scope"],
+ "match_peer_fields": normalized["match_peer_fields"],
+ "match_message": normalized["match_message"],
+ "entries": [
+ {
+ "text": e["text"],
+ "match_mode": e["match_mode"],
+ "enabled": e["enabled"],
+ }
+ for e in normalized["entries"]
+ ],
+ }
+
+
+def parse_import_document(
+ document: dict[str, Any] | None,
+ *,
+ merge: bool = False,
+ existing: dict[str, Any] | None = None,
+) -> dict[str, Any] | None:
+ """Parse a shared blocklist document. Returns normalized blocklist or None."""
+ if not isinstance(document, dict):
+ return None
+
+ schema = document.get("schema")
+ if schema is not None and schema != EXPORT_SCHEMA:
+ return None
+
+ version = document.get("version")
+ if version is not None and int(version) != EXPORT_VERSION:
+ return None
+
+ imported = normalize_message_blocklist(
+ {
+ "scope": document.get("scope"),
+ "match_peer_fields": document.get("match_peer_fields"),
+ "match_message": document.get("match_message"),
+ "entries": document.get("entries"),
+ },
+ )
+
+ if not merge:
+ return imported
+
+ base = normalize_message_blocklist(existing or {})
+ seen = {e["text"].lower() for e in base["entries"]}
+ merged_entries = list(base["entries"])
+ for entry in imported["entries"]:
+ key = entry["text"].lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ merged_entries.append(entry)
+ base["entries"] = merged_entries[:MAX_ENTRIES]
+ return base

diff --git a/meshchatx/src/backend/privacy_mode.py b/meshchatx/src/backend/privacy_mode.py
new file mode 100644
index 00000000..db91edad
--- /dev/null
+++ b/meshchatx/src/backend/privacy_mode.py
@@ -0,0 +1,28 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Privacy mode: block outbound HTTP/HTTPS from the backend and tighten CSP."""
+
+from __future__ import annotations
+
+
+class OutboundHttpBlockedError(RuntimeError):
+ """Raised when privacy mode blocks a server-side HTTP request."""
+
+
+def privacy_mode_enabled(config) -> bool:
+ if config is None:
+ return False
+ getter = getattr(config, "privacy_mode_enabled", None)
+ if getter is None:
+ return False
+ return bool(getter.get())
+
+
+def ensure_outbound_http_allowed(config, *, feature: str = "outbound HTTP") -> None:
+ if privacy_mode_enabled(config):
+ msg = f"Privacy mode is enabled; {feature} is blocked"
+ raise OutboundHttpBlockedError(msg)
+
+
+def csp_allows_external_sources(config) -> bool:
+ return not privacy_mode_enabled(config)

diff --git a/meshchatx/src/frontend/components/map/MapBrowser.vue b/meshchatx/src/frontend/components/map/MapBrowser.vue
new file mode 100644
index 00000000..66aa2e11
--- /dev/null
+++ b/meshchatx/src/frontend/components/map/MapBrowser.vue
@@ -0,0 +1,422 @@
+<!-- SPDX-License-Identifier: 0BSD AND MIT -->
+
+<template>
+ <div class="flex flex-1 min-w-0 h-full flex-col overflow-hidden">
+ <div
+ v-if="showTabStrip"
+ class="flex items-stretch h-9 shrink-0 border-b border-gray-200 dark:border-zinc-800 bg-gray-50 dark:bg-zinc-900 overflow-x-auto"
+ role="tablist"
+ >
+ <button
+ v-for="tab in tabs"
+ :key="tab.id"
+ type="button"
+ role="tab"
+ :aria-selected="tab.id === activeTabId"
+ class="group flex items-center gap-1.5 min-w-[8rem] max-w-[14rem] px-3 border-r border-gray-200 dark:border-zinc-800 text-sm transition-colors"
+ :class="
+ tab.id === activeTabId
+ ? 'bg-white dark:bg-zinc-950 text-gray-900 dark:text-gray-100'
+ : 'text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800'
+ "
+ @click="selectTab(tab.id)"
+ >
+ <MaterialDesignIcon icon-name="map" class="size-4 shrink-0 opacity-70" />
+ <input
+ v-if="renamingTabId === tab.id"
+ ref="renameInput"
+ v-model="renameDraft"
+ type="text"
+ class="flex-1 min-w-0 bg-transparent border-b border-blue-500 outline-none text-sm text-gray-900 dark:text-gray-100"
+ :maxlength="64"
+ @click.stop
+ @keydown.enter.prevent="commitRename"
+ @keydown.esc.prevent="cancelRename"
+ @blur="commitRename"
+ />
+ <span
+ v-else
+ class="truncate flex-1 text-left"
+ :title="$t('map.tab_rename_hint')"
+ @dblclick.stop="startRename(tab.id)"
+ @touchend.stop="onTabLabelTouchEnd(tab, $event)"
+ >
+ {{ tabTitle(tab) }}
+ </span>
+ <span
+ class="shrink-0 rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-zinc-700 dark:hover:text-gray-200"
+ :title="$t('common.cancel')"
+ @click.stop="closeTab(tab.id)"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-3.5" />
+ </span>
+ </button>
+ <button
+ type="button"
+ class="flex items-center justify-center w-9 shrink-0 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ :title="$t('map.new_tab_shortcut')"
+ @click="addTab()"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-5" />
+ </button>
+ </div>
+
+ <div class="flex flex-1 min-h-0 min-w-0 overflow-hidden">
+ <MapPage
+ v-for="tab in tabs"
+ v-show="tab.id === activeTabId"
+ :key="tab.storageId"
+ embedded
+ :tab-storage-id="tab.storageId"
+ :tab-title="tabTitle(tab)"
+ :is-active-tab="tab.id === activeTabId"
+ @update-title="onMapUpdateTitle(tab.id, $event)"
+ />
+ </div>
+ </div>
+</template>
+
+<script>
+import MapPage from "./MapPage.vue";
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import TileCache from "../../js/TileCache";
+import { loadMapTabs, saveMapTabs } from "../../js/browserLayoutStore";
+
+const LEGACY_MAP_STATE_KEY = "last_view";
+const DOUBLE_TAP_MS = 400;
+
+function createStorageId() {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
+ }
+ return `map-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
+export default {
+ name: "MapBrowser",
+ components: {
+ MapPage,
+ MaterialDesignIcon,
+ },
+ data() {
+ return {
+ tabs: [],
+ activeTabId: null,
+ nextTabId: 1,
+ nextTabNumber: 1,
+ isWideViewport: false,
+ mediaQuery: null,
+ mediaQueryListener: null,
+ renamingTabId: null,
+ renameDraft: "",
+ lastLabelTap: { tabId: null, time: 0 },
+ };
+ },
+ computed: {
+ showTabStrip() {
+ return this.tabs.length > 0;
+ },
+ activeTab() {
+ return this.tabs.find((tab) => tab.id === this.activeTabId) || null;
+ },
+ tabLayoutSignature() {
+ const tabs = this.tabs
+ .map((tab) => `${tab.storageId || ""}|${tab.title || ""}|${tab.userRenamed ? "1" : "0"}`)
+ .join("\u241f");
+ const activeIndex = this.tabs.findIndex((tab) => tab.id === this.activeTabId);
+ return `${activeIndex}\u241e${tabs}`;
+ },
+ },
+ watch: {
+ tabLayoutSignature() {
+ this.persistTabs();
+ },
+ },
+ async mounted() {
+ this.setupViewportWatcher();
+ window.addEventListener("keydown", this.handleKeydown, true);
+
+ if (!(await this.restoreTabs())) {
+ const storageId = createStorageId();
+ await this.migrateLegacyMapState(storageId);
+ await this.addTab(null, true, storageId);
+ }
+ },
+ beforeUnmount() {
+ this.teardownViewportWatcher();
+ window.removeEventListener("keydown", this.handleKeydown, true);
+ },
+ methods: {
+ setupViewportWatcher() {
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
+ this.isWideViewport = false;
+ return;
+ }
+ this.mediaQuery = window.matchMedia("(min-width: 768px)");
+ this.isWideViewport = this.mediaQuery.matches;
+ this.mediaQueryListener = (event) => {
+ this.isWideViewport = event.matches;
+ };
+ if (typeof this.mediaQuery.addEventListener === "function") {
+ this.mediaQuery.addEventListener("change", this.mediaQueryListener);
+ } else if (typeof this.mediaQuery.addListener === "function") {
+ this.mediaQuery.addListener(this.mediaQueryListener);
+ }
+ },
+ teardownViewportWatcher() {
+ if (!this.mediaQuery || !this.mediaQueryListener) {
+ return;
+ }
+ if (typeof this.mediaQuery.removeEventListener === "function") {
+ this.mediaQuery.removeEventListener("change", this.mediaQueryListener);
+ } else if (typeof this.mediaQuery.removeListener === "function") {
+ this.mediaQuery.removeListener(this.mediaQueryListener);
+ }
+ this.mediaQuery = null;
+ this.mediaQueryListener = null;
+ },
+ defaultTabTitle(tabNumber = this.nextTabNumber) {
+ return this.$t("map.tab_default_name", { number: tabNumber });
+ },
+ addTab(title = null, activate = true, storageId = null) {
+ const tabNumber = this.nextTabNumber++;
+ const id = this.nextTabId++;
+ const resolvedStorageId = storageId || createStorageId();
+ this.tabs.push({
+ id,
+ storageId: resolvedStorageId,
+ title: title || this.defaultTabTitle(tabNumber),
+ userRenamed: Boolean(title),
+ tabNumber,
+ });
+ if (activate) {
+ this.activeTabId = id;
+ }
+ return id;
+ },
+ tabTitle(tab) {
+ if (tab.title) {
+ return tab.title;
+ }
+ return this.$t("map.new_tab");
+ },
+ onMapUpdateTitle(tabId, title) {
+ const tab = this.tabs.find((entry) => entry.id === tabId);
+ if (!tab || tab.userRenamed) {
+ return;
+ }
+ const trimmed = typeof title === "string" ? title.trim() : "";
+ if (!trimmed) {
+ return;
+ }
+ tab.title = trimmed.slice(0, 64);
+ },
+ startRename(tabId) {
+ const tab = this.tabs.find((entry) => entry.id === tabId);
+ if (!tab) {
+ return;
+ }
+ this.renamingTabId = tabId;
+ this.renameDraft = this.tabTitle(tab);
+ this.$nextTick(() => {
+ const input = this.$refs.renameInput;
+ const el = Array.isArray(input) ? input.find((node) => node) : input;
+ el?.focus?.();
+ el?.select?.();
+ });
+ },
+ commitRename() {
+ if (this.renamingTabId == null) {
+ return;
+ }
+ const tab = this.tabs.find((entry) => entry.id === this.renamingTabId);
+ if (tab) {
+ const trimmed = this.renameDraft.trim();
+ tab.title = trimmed || this.defaultTabTitle(this.nextTabNumber - 1);
+ tab.userRenamed = Boolean(trimmed);
+ }
+ this.renamingTabId = null;
+ this.renameDraft = "";
+ },
+ cancelRename() {
+ this.renamingTabId = null;
+ this.renameDraft = "";
+ },
+ onTabLabelTouchEnd(tab, event) {
+ const now = Date.now();
+ if (this.lastLabelTap.tabId === tab.id && now - this.lastLabelTap.time <= DOUBLE_TAP_MS) {
+ this.lastLabelTap = { tabId: null, time: 0 };
+ this.startRename(tab.id);
+ event.preventDefault();
+ return;
+ }
+ this.lastLabelTap = { tabId: tab.id, time: now };
+ },
+ selectRelativeTab(offset) {
+ if (this.tabs.length < 2) {
+ return;
+ }
+ const index = this.tabs.findIndex((tab) => tab.id === this.activeTabId);
+ if (index === -1) {
+ return;
+ }
+ const nextIndex = (index + offset + this.tabs.length) % this.tabs.length;
+ this.selectTab(this.tabs[nextIndex].id);
+ },
+ selectTabByIndex(index) {
+ if (index >= 0 && index < this.tabs.length) {
+ this.selectTab(this.tabs[index].id);
+ }
+ },
+ handleKeydown(event) {
+ if (this.$route?.name !== "map") {
+ return;
+ }
+
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
+ const mod = isMac ? event.metaKey : event.ctrlKey;
+ const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
+ const isInput =
+ ["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName) ||
+ document.activeElement?.isContentEditable;
+ if (isInput && !hasModifier) {
+ return;
+ }
+
+ const key = event.key.toLowerCase();
+
+ if (mod && key === "t") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.addTab();
+ return;
+ }
+ if (mod && key === "w") {
+ event.preventDefault();
+ event.stopPropagation();
+ if (this.activeTabId != null) {
+ this.closeTab(this.activeTabId);
+ }
+ return;
+ }
+ if (event.ctrlKey && key === "tab") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.selectRelativeTab(event.shiftKey ? -1 : 1);
+ return;
+ }
+ if (event.ctrlKey && key === "pageup") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.selectRelativeTab(-1);
+ return;
+ }
+ if (event.ctrlKey && key === "pagedown") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.selectRelativeTab(1);
+ return;
+ }
+ if (mod && key >= "1" && key <= "9") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.selectTabByIndex(parseInt(key, 10) - 1);
+ }
+ },
+ async migrateLegacyMapState(storageId) {
+ try {
+ const legacy = await TileCache.getMapState(LEGACY_MAP_STATE_KEY);
+ if (!legacy) {
+ return;
+ }
+ const tabKey = `map_tab_${storageId}`;
+ const existing = await TileCache.getMapState(tabKey);
+ if (!existing) {
+ await TileCache.setMapState(tabKey, legacy);
+ }
+ } catch {
+ // migration is best-effort
+ }
+ },
+ async restoreTabs() {
+ const saved = loadMapTabs();
+ if (!saved || saved.tabs.length === 0) {
+ return false;
+ }
+
+ let maxTabNumber = 0;
+ this.tabs = saved.tabs.map((tab, index) => {
+ const tabNumber = Number.isInteger(tab.tabNumber) && tab.tabNumber > 0 ? tab.tabNumber : index + 1;
+ maxTabNumber = Math.max(maxTabNumber, tabNumber);
+ return {
+ id: this.nextTabId++,
+ storageId: typeof tab.storageId === "string" && tab.storageId ? tab.storageId : createStorageId(),
+ title: typeof tab.title === "string" && tab.title ? tab.title : this.defaultTabTitle(tabNumber),
+ userRenamed: tab.userRenamed === true,
+ tabNumber,
+ };
+ });
+
+ if (this.tabs.length === 0) {
+ return false;
+ }
+
+ this.nextTabNumber = maxTabNumber + 1;
+
+ const activeIndex =
+ Number.isInteger(saved.activeIndex) && saved.activeIndex >= 0 && saved.activeIndex < this.tabs.length
+ ? saved.activeIndex
+ : 0;
+ this.activeTabId = this.tabs[activeIndex].id;
+
+ await this.migrateLegacyMapState(this.tabs[0].storageId);
+ return true;
+ },
+ persistTabs() {
+ const activeIndex = this.tabs.findIndex((tab) => tab.id === this.activeTabId);
+ saveMapTabs({
+ tabs: this.tabs.map((tab) => ({
+ storageId: tab.storageId,
+ title: tab.title || null,
+ userRenamed: tab.userRenamed === true,
+ tabNumber: tab.tabNumber || null,
+ })),
+ activeIndex: activeIndex < 0 ? 0 : activeIndex,
+ });
+ },
+ selectTab(tabId) {
+ if (this.renamingTabId != null) {
+ this.commitRename();
+ }
+ if (this.activeTabId === tabId) {
+ return;
+ }
+ this.activeTabId = tabId;
+ },
+ closeTab(tabId) {
+ if (this.renamingTabId === tabId) {
+ this.cancelRename();
+ }
+
+ const index = this.tabs.findIndex((tab) => tab.id === tabId);
+ if (index === -1) {
+ return;
+ }
+
+ const closing = this.tabs[index];
+ const wasActive = closing.id === this.activeTabId;
+ this.tabs.splice(index, 1);
+
+ if (this.tabs.length === 0) {
+ this.addTab();
+ return;
+ }
+
+ if (wasActive) {
+ const neighbour = this.tabs[index] || this.tabs[index - 1] || this.tabs[0];
+ this.activeTabId = neighbour.id;
+ }
+ },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index 0a865859..7117f3f3 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -9,7 +9,7 @@
<div class="hidden sm:flex items-center min-w-0 gap-2">
<v-icon icon="mdi-map" class="text-blue-500 dark:text-blue-400 shrink-0" size="24"></v-icon>
<h1 class="text-lg sm:text-xl font-black text-gray-900 dark:text-white truncate">
- {{ $t("map.title") }}
+ {{ embedded && tabTitle ? tabTitle : $t("map.title") }}
</h1>
</div>
@@ -1204,6 +1204,25 @@ export default {
MapLoadingOverlay,
MapVectorExchangePanel,
},
+ props: {
+ embedded: {
+ type: Boolean,
+ default: false,
+ },
+ tabStorageId: {
+ type: String,
+ default: "",
+ },
+ tabTitle: {
+ type: String,
+ default: "",
+ },
+ isActiveTab: {
+ type: Boolean,
+ default: true,
+ },
+ },
+ emits: ["update-title"],
data() {
return {
map: null,
@@ -1356,6 +1375,12 @@ export default {
};
},
computed: {
+ mapStateKey() {
+ if (this.tabStorageId) {
+ return `map_tab_${this.tabStorageId}`;
+ }
+ return "last_view";
+ },
popoutRouteType() {
if (this.$route?.meta?.popoutType) {
return this.$route.meta.popoutType;
@@ -1454,6 +1479,9 @@ export default {
if (name !== "map" && name !== "map-popout") {
return;
}
+ if (this.embedded && !this.isActiveTab) {
+ return;
+ }
if (!this.map || !this.markerSource) {
return;
}
@@ -1461,13 +1489,26 @@ export default {
},
deep: true,
},
+ isActiveTab(active) {
+ if (!active || !this.map) {
+ return;
+ }
+ this.$nextTick(() => {
+ if (this.map && typeof this.map.updateSize === "function") {
+ this.map.updateSize();
+ }
+ });
+ if (this.embedded) {
+ this.applyMapViewFromRoute();
+ }
+ },
},
async mounted() {
await this.getConfig();
// Load persisted map state
try {
- const savedState = await TileCache.getMapState("last_view");
+ const savedState = await TileCache.getMapState(this.mapStateKey);
if (savedState) {
this.currentCenter = savedState.center || [0, 0];
this.currentZoom = savedState.zoom || 2;
@@ -1655,7 +1696,7 @@ export default {
telemetry: this.telemetryList,
})
);
- await TileCache.setMapState("last_view", state);
+ await TileCache.setMapState(this.mapStateKey, state);
console.log("Map state persisted to cache, drawings size:", drawings ? drawings.length : 0);
} catch (e) {
console.error("Failed to save map state", e);
@@ -3158,6 +3199,10 @@ export default {
}
this.clearSearch();
+ const label = (result.display_name || result.name || "").trim();
+ if (label) {
+ this.$emit("update-title", label);
+ }
},
clearSearch() {
this.searchQuery = "";

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 486981a9..cf55db0b 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -170,6 +170,7 @@ export default {
iconQueueRunning: false,
iconQueueGeneration: 0,
lodRafId: null,
+ vizRunGeneration: 0,
};
},
computed: {
@@ -570,8 +571,8 @@ export default {
interaction: {
tooltipDelay: 100,
hover: true,
- hideEdgesOnDrag: true,
- hideEdgesOnZoom: true,
+ hideEdgesOnDrag: false,
+ hideEdgesOnZoom: false,
},
layout: {
randomSeed: 42,
@@ -613,11 +614,18 @@ export default {
shadow: false,
},
edges: {
- // "continuous" computes bezier curves on every frame and
- // is noticeably heavier than straight edges on slow ARM
- // CPUs once you have a few hundred edges. Straight edges
- // still look clean against the dotted background.
- smooth: false,
+ /*
+ * Keep smooth as an object. Passing the boolean `false`
+ * makes vis-network 9.x throw in updateEdgeType() on any
+ * later setOptions() call (it reads smooth.type.enabled),
+ * which aborts the edge redraw and makes all links vanish.
+ * "continuous" renders correctly under the physics layout.
+ */
+ smooth: {
+ enabled: true,
+ type: "continuous",
+ roundness: 0.5,
+ },
selectionWidth: 3,
hoverWidth: 2,
color: {
@@ -779,6 +787,8 @@ export default {
});
if (this.abortController.signal.aborted) return;
+ const runId = ++this.vizRunGeneration;
+
this.loadingStatus = "Processing visualization...";
/*
@@ -802,6 +812,25 @@ export default {
this.network.setOptions({ physics: { enabled: false } });
}
+ try {
+ await this._processVisualizationGraph(runId);
+ } finally {
+ if (runId === this.vizRunGeneration) {
+ if (this.network && !this.didDisableStabilization) {
+ this.didDisableStabilization = true;
+ this.network.setOptions({ physics: { stabilization: { enabled: false } } });
+ }
+ if (physicsWasOn && this.network) {
+ this.network.setOptions({ physics: { enabled: this.enablePhysics } });
+ }
+ if (this.network && typeof this.network.redraw === "function") {
+ this.network.redraw();
+ }
+ }
+ }
+ },
+ async _processVisualizationGraph(runId) {
+ const isCurrentRun = () => runId === this.vizRunGeneration && !this.abortController.signal.aborted;
const processedNodeIds = new Set();
const processedEdgeIds = new Set();
@@ -986,7 +1015,7 @@ export default {
if (discoveredNodes.length > 0) this.nodes.update(discoveredNodes);
if (discoveredEdges.length > 0) this.edges.update(discoveredEdges);
- if (this.abortController.signal.aborted) return;
+ if (!isCurrentRun()) return;
// Process path table in batches to prevent UI block
this.totalNodesToLoad = this.pathTable.length;
@@ -1007,7 +1036,7 @@ export default {
this.currentBatch = 0;
for (let i = 0; i < this.pathTable.length; i += chunkSize) {
- if (this.abortController.signal.aborted) return;
+ if (!isCurrentRun()) return;
this.currentBatch++;
const chunk = this.pathTable.slice(i, i + chunkSize);
const batchNodes = [];
@@ -1165,9 +1194,11 @@ export default {
*/
await yieldToMain();
- if (this.abortController.signal.aborted) return;
+ if (!isCurrentRun()) return;
}
+ if (!isCurrentRun()) return;
+
// Cleanup: remove nodes/edges that are no longer in the network
const nodesToRemove = this.nodes.getIds().filter((id) => !processedNodeIds.has(id));
if (nodesToRemove.length > 0) this.nodes.remove(nodesToRemove);
@@ -1180,20 +1211,6 @@ export default {
this.currentBatch = 0;
this.totalBatches = 0;
- if (this.network && !this.didDisableStabilization) {
- this.didDisableStabilization = true;
- this.network.setOptions({ physics: { stabilization: { enabled: false } } });
- }
-
- /*
- * Re-enable physics now that all nodes/edges are in place. The
- * solver runs once on the final graph instead of repeatedly on
- * partial states, which is dramatically cheaper.
- */
- if (physicsWasOn && this.network) {
- this.network.setOptions({ physics: { enabled: this.enablePhysics } });
- }
-
this.scheduleIconQueue();
},
scheduleIconQueue() {

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 3535e01f..a9430ba4 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -2018,6 +2018,27 @@
</div>
</div>
+ <div class="border-t border-gray-200 dark:border-zinc-800 pt-4 space-y-3">
+ <div
+ class="text-[11px] font-semibold uppercase tracking-wider text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("app.privacy_eyebrow") }}
+ </div>
+ <label class="setting-toggle">
+ <Toggle
+ id="privacy-mode-enabled"
+ v-model="config.privacy_mode_enabled"
+ @update:model-value="onPrivacyModeChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.privacy_mode_enabled") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.privacy_mode_description")
+ }}</span>
+ </span>
+ </label>
+ </div>
+
<div class="border-t border-gray-200 dark:border-zinc-800 pt-4 space-y-4">
<div
class="text-[11px] font-semibold uppercase tracking-wider text-gray-500 dark:text-zinc-400"
@@ -2118,6 +2139,116 @@
</div>
</section>
+ <section
+ v-show="matchesSearch(...sectionKeywords.webExposure)"
+ class="settings-section break-inside-avoid"
+ >
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Security</div>
+ <h2>{{ $t("app.web_exposure_title") }}</h2>
+ <p>{{ $t("app.web_exposure_description") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
+ <div>
+ <div class="text-gray-500 dark:text-zinc-400">
+ {{ $t("app.web_listen_address") }}
+ </div>
+ <div class="font-mono text-gray-900 dark:text-gray-100">
+ {{ serverSecurity.listen_host || "—" }}:{{ serverSecurity.listen_port ?? "—" }}
+ </div>
+ </div>
+ <div>
+ <div class="text-gray-500 dark:text-zinc-400">{{ $t("app.web_listen_https") }}</div>
+ <div class="text-gray-900 dark:text-gray-100">
+ {{ serverSecurity.https_enabled ? $t("app.enabled") : $t("app.disabled") }}
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="serverSecurity.landlock_requested !== undefined"
+ class="text-xs text-gray-600 dark:text-gray-400"
+ >
+ {{ $t("app.landlock_status") }}:
+ {{
+ serverSecurity.landlock_active
+ ? serverSecurity.landlock_auto_enabled
+ ? $t("app.landlock_auto_enabled")
+ : $t("app.landlock_active")
+ : serverSecurity.landlock_kernel_supported === false
+ ? $t("app.landlock_kernel_unsupported")
+ : serverSecurity.landlock_disabled_by_env
+ ? $t("app.landlock_disabled_by_env")
+ : $t("app.landlock_inactive")
+ }}
+ </div>
+ <div
+ v-if="serverSecurity.is_loopback_bind === false"
+ class="rounded-md border border-amber-500/40 bg-amber-500/10 p-4 space-y-3"
+ >
+ <div class="text-sm font-semibold text-amber-900 dark:text-amber-200">
+ {{ $t("app.web_exposure_warning_title") }}
+ </div>
+ <p class="text-sm text-amber-950/90 dark:text-amber-100/90">
+ {{ $t("app.web_exposure_warning_body") }}
+ </p>
+ <ul class="space-y-2 text-sm">
+ <li class="flex items-start gap-2">
+ <MaterialDesignIcon
+ :icon-name="serverSecurity.auth_enabled ? 'check-circle' : 'alert-circle'"
+ class="size-4 mt-0.5 shrink-0"
+ :class="serverSecurity.auth_enabled ? 'text-green-600' : 'text-amber-600'"
+ />
+ <span>{{
+ serverSecurity.auth_enabled
+ ? $t("app.web_exposure_check_auth")
+ : $t("app.web_exposure_check_auth_off")
+ }}</span>
+ </li>
+ <li>
+ <label class="flex items-start gap-2 cursor-pointer">
+ <input
+ v-model="exposureAckFirewall"
+ type="checkbox"
+ class="rounded-sm mt-1"
+ @change="persistExposureAcknowledgements"
+ />
+ <span>{{ $t("app.web_exposure_check_firewall") }}</span>
+ </label>
+ </li>
+ <li>
+ <label class="flex items-start gap-2 cursor-pointer">
+ <input
+ v-model="exposureAckVpn"
+ type="checkbox"
+ class="rounded-sm mt-1"
+ @change="persistExposureAcknowledgements"
+ />
+ <span>{{ $t("app.web_exposure_check_vpn") }}</span>
+ </label>
+ </li>
+ </ul>
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.web_ui_ip_allowlist") }}
+ </div>
+ <input
+ v-model="serverSecurity.web_ui_ip_allowlist"
+ type="text"
+ class="input-field font-mono text-xs"
+ :placeholder="$t('app.web_ui_ip_allowlist_placeholder')"
+ @input="onWebUiAllowlistChange"
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("app.web_ui_ip_allowlist_description") }}
+ </div>
+ </div>
+ </div>
+ </section>
+
<!-- Sources & Infrastructure -->
<section
v-show="matchesSearch(...sectionKeywords.infrastructure)"
@@ -2812,7 +2943,23 @@ export default {
local_message_auto_delete_enabled: false,
local_message_auto_delete_value: 30,
local_message_auto_delete_unit: "days",
+ privacy_mode_enabled: false,
},
+ serverSecurity: {
+ listen_host: null,
+ listen_port: null,
+ https_enabled: true,
+ is_loopback_bind: true,
+ web_ui_ip_allowlist: "",
+ auth_enabled: false,
+ landlock_requested: false,
+ landlock_active: false,
+ landlock_kernel_supported: false,
+ landlock_auto_enabled: false,
+ landlock_disabled_by_env: false,
+ },
+ exposureAckFirewall: false,
+ exposureAckVpn: false,
saveTimeouts: {},
lxmfIncomingDeliveryPreset: "10mb",
lxmfIncomingDeliveryCustomAmount: 10,
@@ -3060,6 +3207,21 @@ export default {
],
blocked: ["Privacy", "Banished", "Manage Banished users and nodes"],
auth: ["Security", "Authentication", "password", "Protect your instance with a password"],
+ webExposure: [
+ "Security",
+ "Network exposure",
+ "app.web_exposure_title",
+ "app.web_exposure_description",
+ "app.web_listen_address",
+ "app.web_ui_ip_allowlist",
+ "app.web_exposure_warning_title",
+ "app.landlock_status",
+ "allowlist",
+ "firewall",
+ "VPN",
+ "bind",
+ "localhost",
+ ],
infrastructure: ["Infrastructure", "Sources & Mirroring", "gitea", "documentation", "download", "urls"],
messages: [
"app.lxmf_settings_eyebrow",
@@ -3115,6 +3277,8 @@ export default {
privacyData: [
"app.privacy_data_title",
"app.privacy_data_description",
+ "app.privacy_mode_enabled",
+ "app.privacy_mode_description",
"app.local_message_auto_delete_title",
"app.local_message_auto_delete_description",
"app.local_message_auto_delete_age",
@@ -3197,6 +3361,8 @@ export default {
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getConfig();
+ this.getServerSecurity();
+ this.loadExposureAcknowledgements();
this.getTrustedTelemetryPeers();
this.loadStickerCount();
this.loadGifCount();
@@ -3308,6 +3474,51 @@ export default {
console.log(e);
}
},
+ loadExposureAcknowledgements() {
+ try {
+ this.exposureAckFirewall = localStorage.getItem("meshchatx_exposure_ack_firewall") === "1";
+ this.exposureAckVpn = localStorage.getItem("meshchatx_exposure_ack_vpn") === "1";
+ } catch {
+ this.exposureAckFirewall = false;
+ this.exposureAckVpn = false;
+ }
+ },
+ persistExposureAcknowledgements() {
+ try {
+ localStorage.setItem("meshchatx_exposure_ack_firewall", this.exposureAckFirewall ? "1" : "0");
+ localStorage.setItem("meshchatx_exposure_ack_vpn", this.exposureAckVpn ? "1" : "0");
+ } catch {
+ // ignore storage failures
+ }
+ },
+ async getServerSecurity() {
+ try {
+ const response = await window.api.get("/api/v1/server/security");
+ this.serverSecurity = { ...this.serverSecurity, ...response.data };
+ } catch (e) {
+ console.log(e);
+ }
+ },
+ async onPrivacyModeChange(value) {
+ await this.updateConfig({ privacy_mode_enabled: value }, "privacy_mode_enabled");
+ },
+ onWebUiAllowlistChange() {
+ if (this.saveTimeouts.webUiAllowlist) clearTimeout(this.saveTimeouts.webUiAllowlist);
+ this.saveTimeouts.webUiAllowlist = setTimeout(async () => {
+ try {
+ const response = await window.api.patch("/api/v1/server/security", {
+ web_ui_ip_allowlist: this.serverSecurity.web_ui_ip_allowlist,
+ });
+ this.serverSecurity = { ...this.serverSecurity, ...response.data };
+ ToastUtils.success(
+ this.$t("app.setting_auto_saved", { label: this.$t("app.web_ui_ip_allowlist") })
+ );
+ } catch (e) {
+ ToastUtils.error(this.$t("common.save_failed"));
+ console.log(e);
+ }
+ }, 800);
+ },
getKeyboardShortcuts() {
WebSocketConnection.send(
JSON.stringify({
@@ -4033,6 +4244,7 @@ export default {
},
"authentication"
);
+ this.serverSecurity.auth_enabled = !!value;
if (value) {
// if enabled, redirect to setup page if password not set

diff --git a/meshchatx/src/frontend/components/tools/MessageBlocklistPage.vue b/meshchatx/src/frontend/components/tools/MessageBlocklistPage.vue
new file mode 100644
index 00000000..cde8fb70
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/MessageBlocklistPage.vue
@@ -0,0 +1,390 @@
+<!-- SPDX-License-Identifier: 0BSD AND MIT -->
+
+<template>
+ <div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
+ <ToolsPageHeader
+ icon="shield-alert"
+ :title="$t('tools.message_blocklist.title')"
+ :description="$t('tools.message_blocklist.description')"
+ accent="rose"
+ />
+ <div class="flex-1 overflow-y-auto w-full pb-[max(1rem,env(safe-area-inset-bottom))]">
+ <div class="p-3 sm:p-4 md:p-6 max-w-4xl mx-auto w-full space-y-4 min-w-0">
+ <div
+ class="rounded-xl border border-amber-200 dark:border-amber-900/50 bg-amber-50 dark:bg-amber-950/30 px-4 py-3 flex items-start gap-3"
+ >
+ <MaterialDesignIcon
+ icon-name="alert-circle-outline"
+ class="size-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5"
+ />
+ <div class="min-w-0">
+ <div class="text-sm font-semibold text-amber-900 dark:text-amber-200">
+ {{ $t("tools.message_blocklist.beta_notice_title") }}
+ </div>
+ <p class="text-xs text-amber-800/90 dark:text-amber-300/90 mt-1 leading-relaxed">
+ {{ $t("tools.message_blocklist.beta_notice_body") }}
+ </p>
+ </div>
+ </div>
+
+ <div
+ class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-4"
+ >
+ <label class="inline-flex items-center gap-3 cursor-pointer">
+ <input
+ v-model="enabled"
+ type="checkbox"
+ class="rounded-sm border-gray-300 size-4"
+ @change="onEnabledChange"
+ />
+ <span class="text-sm font-medium text-gray-900 dark:text-white">
+ {{ $t("tools.message_blocklist.enable_label") }}
+ </span>
+ </label>
+ <p class="text-xs text-gray-500 dark:text-gray-400">
+ {{ $t("tools.message_blocklist.enable_hint") }}
+ </p>
+ </div>
+
+ <div
+ class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-4"
+ :class="enabled ? '' : 'opacity-60'"
+ >
+ <div class="flex flex-wrap items-center justify-between gap-2">
+ <h2 class="text-base font-semibold text-gray-900 dark:text-white">
+ {{ $t("tools.message_blocklist.entries_heading") }}
+ </h2>
+ <div class="flex flex-wrap items-center gap-2">
+ <button
+ type="button"
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border border-gray-200 dark:border-zinc-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-900 transition-colors"
+ @click="exportList"
+ >
+ <MaterialDesignIcon icon-name="export" class="size-4" />
+ {{ $t("tools.message_blocklist.export") }}
+ </button>
+ <button
+ type="button"
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border border-gray-200 dark:border-zinc-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-900 transition-colors"
+ @click="triggerImport"
+ >
+ <MaterialDesignIcon icon-name="import" class="size-4" />
+ {{ $t("tools.message_blocklist.import") }}
+ </button>
+ <input
+ ref="importFileInput"
+ type="file"
+ accept=".json,application/json"
+ class="hidden"
+ @change="onImportFile"
+ />
+ <button
+ type="button"
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-rose-600 text-white hover:bg-rose-700 transition-colors"
+ @click="addEntry"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-4" />
+ {{ $t("tools.message_blocklist.add_entry") }}
+ </button>
+ </div>
+ </div>
+
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+ <div>
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
+ >{{ $t("tools.message_blocklist.scope_label") }}</label
+ >
+ <select
+ v-model="blocklist.scope"
+ class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
+ >
+ <option value="everyone">
+ {{ $t("tools.message_blocklist.scope_everyone") }}
+ </option>
+ <option value="contacts">
+ {{ $t("tools.message_blocklist.scope_contacts") }}
+ </option>
+ <option value="non_contacts">
+ {{ $t("tools.message_blocklist.scope_non_contacts") }}
+ </option>
+ </select>
+ </div>
+ <div class="space-y-2">
+ <div
+ class="text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
+ >
+ {{ $t("tools.message_blocklist.match_in_label") }}
+ </div>
+ <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
+ <input
+ v-model="blocklist.match_message"
+ type="checkbox"
+ class="rounded-sm border-gray-300"
+ />
+ {{ $t("tools.message_blocklist.match_message") }}
+ </label>
+ <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
+ <input
+ v-model="blocklist.match_peer_fields"
+ type="checkbox"
+ class="rounded-sm border-gray-300"
+ />
+ {{ $t("tools.message_blocklist.match_peer_fields") }}
+ </label>
+ </div>
+ </div>
+
+ <div
+ v-if="blocklist.entries.length === 0"
+ class="text-sm text-gray-500 dark:text-gray-400 py-6 text-center"
+ >
+ {{ $t("tools.message_blocklist.empty_entries") }}
+ </div>
+
+ <div v-else class="space-y-3">
+ <div
+ v-for="(entry, index) in blocklist.entries"
+ :key="entry.id"
+ class="rounded-lg border border-gray-200 dark:border-zinc-800 p-3 space-y-3 bg-gray-50/80 dark:bg-zinc-900/40"
+ >
+ <div class="flex flex-wrap items-center justify-between gap-2">
+ <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
+ <input v-model="entry.enabled" type="checkbox" class="rounded-sm border-gray-300" />
+ {{ $t("tools.message_blocklist.entry_enabled") }}
+ </label>
+ <div class="flex items-center gap-1">
+ <button
+ type="button"
+ class="p-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-950/40"
+ :title="$t('tools.message_blocklist.remove_entry')"
+ @click="removeEntry(index)"
+ >
+ <MaterialDesignIcon icon-name="delete-outline" class="size-5" />
+ </button>
+ </div>
+ </div>
+ <div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2">
+ <input
+ v-model="entry.text"
+ type="text"
+ class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white font-mono"
+ :placeholder="$t('tools.message_blocklist.entry_placeholder')"
+ />
+ <select
+ v-model="entry.match_mode"
+ class="px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
+ >
+ <option value="substring">
+ {{ $t("tools.message_blocklist.match_mode_substring") }}
+ </option>
+ <option value="regex">
+ {{ $t("tools.message_blocklist.match_mode_regex") }}
+ </option>
+ </select>
+ </div>
+ </div>
+ </div>
+
+ <div class="flex flex-wrap items-center gap-2 pt-2">
+ <button
+ type="button"
+ class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 transition-colors"
+ :disabled="isSaving"
+ @click="save"
+ >
+ <MaterialDesignIcon icon-name="content-save-outline" class="size-4" />
+ <span v-if="isSaving">{{ $t("tools.message_blocklist.saving") }}</span>
+ <span v-else>{{ $t("tools.message_blocklist.save") }}</span>
+ </button>
+ <button
+ type="button"
+ class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 dark:border-zinc-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-900 transition-colors"
+ @click="reload"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-4" />
+ {{ $t("tools.message_blocklist.revert") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import ToolsPageHeader from "./ToolsPageHeader.vue";
+import ToastUtils from "../../js/ToastUtils";
+import DownloadUtils from "../../js/DownloadUtils";
+import DialogUtils from "../../js/DialogUtils";
+
+function newEntryId() {
+ return Math.random().toString(16).slice(2, 18);
+}
+
+export default {
+ name: "MessageBlocklistPage",
+ components: {
+ MaterialDesignIcon,
+ ToolsPageHeader,
+ },
+ data() {
+ return {
+ enabled: false,
+ blocklist: {
+ scope: "non_contacts",
+ match_peer_fields: false,
+ match_message: true,
+ entries: [],
+ },
+ isSaving: false,
+ };
+ },
+ async mounted() {
+ await this.reload();
+ },
+ methods: {
+ mapFromApi(raw) {
+ const scope = raw.scope === "contacts" || raw.scope === "non_contacts" ? raw.scope : "everyone";
+ const match_peer_fields = !!raw.match_peer_fields;
+ const match_message = raw.match_message !== false;
+ const entries = Array.isArray(raw.entries)
+ ? raw.entries.map((e) => ({
+ id: e.id || newEntryId(),
+ enabled: e.enabled !== false,
+ text: e.text || "",
+ match_mode: e.match_mode === "regex" ? "regex" : "substring",
+ }))
+ : [];
+ return {
+ scope,
+ match_peer_fields,
+ match_message: match_peer_fields || match_message ? match_message : true,
+ entries,
+ };
+ },
+ normalizeForSave() {
+ const match_peer_fields = !!this.blocklist.match_peer_fields;
+ const match_message = !!this.blocklist.match_message;
+ const targets_ok = match_peer_fields || match_message;
+ return {
+ scope:
+ this.blocklist.scope === "contacts" || this.blocklist.scope === "non_contacts"
+ ? this.blocklist.scope
+ : "everyone",
+ match_peer_fields: targets_ok ? match_peer_fields : false,
+ match_message: targets_ok ? match_message : true,
+ entries: (this.blocklist.entries || []).map((e) => ({
+ id: e.id,
+ enabled: !!e.enabled,
+ text: String(e.text || "").trim(),
+ match_mode: e.match_mode === "regex" ? "regex" : "substring",
+ })),
+ };
+ },
+ addEntry() {
+ this.blocklist.entries.push({
+ id: newEntryId(),
+ enabled: true,
+ text: "",
+ match_mode: "substring",
+ });
+ },
+ removeEntry(index) {
+ this.blocklist.entries.splice(index, 1);
+ },
+ async reload() {
+ try {
+ const res = await window.api.get("/api/v1/lxmf/message-blocklist");
+ this.enabled = !!res.data.enabled;
+ this.blocklist = this.mapFromApi(res.data.blocklist || {});
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("tools.message_blocklist.load_failed"));
+ }
+ },
+ async save() {
+ this.isSaving = true;
+ try {
+ const payload = {
+ enabled: this.enabled,
+ blocklist: this.normalizeForSave(),
+ };
+ const res = await window.api.put("/api/v1/lxmf/message-blocklist", payload);
+ this.enabled = !!res.data.enabled;
+ this.blocklist = this.mapFromApi(res.data.blocklist || {});
+ ToastUtils.success(this.$t("tools.message_blocklist.saved"));
+ } catch (e) {
+ const msg =
+ (e.response && e.response.data && e.response.data.message) ||
+ e.message ||
+ this.$t("tools.message_blocklist.save_failed");
+ ToastUtils.error(msg);
+ } finally {
+ this.isSaving = false;
+ }
+ },
+ async onEnabledChange() {
+ try {
+ await window.api.put("/api/v1/lxmf/message-blocklist", {
+ enabled: this.enabled,
+ blocklist: this.normalizeForSave(),
+ });
+ ToastUtils.success(
+ this.enabled
+ ? this.$t("tools.message_blocklist.enabled_toast")
+ : this.$t("tools.message_blocklist.disabled_toast")
+ );
+ } catch {
+ this.enabled = !this.enabled;
+ ToastUtils.error(this.$t("tools.message_blocklist.save_failed"));
+ }
+ },
+ async exportList() {
+ try {
+ const res = await window.api.get("/api/v1/lxmf/message-blocklist/export");
+ const blob = new Blob([JSON.stringify(res.data, null, 2)], {
+ type: "application/json",
+ });
+ await DownloadUtils.downloadFile("meshchatx_message_blocklist.json", blob);
+ ToastUtils.success(this.$t("tools.message_blocklist.exported"));
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("tools.message_blocklist.export_failed"));
+ }
+ },
+ triggerImport() {
+ this.$refs.importFileInput?.click();
+ },
+ async onImportFile(event) {
+ const file = event.target.files && event.target.files[0];
+ event.target.value = "";
+ if (!file) {
+ return;
+ }
+ const merge = await DialogUtils.confirm(this.$t("tools.message_blocklist.import_merge_confirm"));
+ try {
+ const text = await file.text();
+ const document = JSON.parse(text);
+ const res = await window.api.post("/api/v1/lxmf/message-blocklist/import", {
+ document,
+ merge,
+ });
+ this.blocklist = this.mapFromApi(res.data.blocklist || {});
+ ToastUtils.success(
+ merge
+ ? this.$t("tools.message_blocklist.imported_merge")
+ : this.$t("tools.message_blocklist.imported_replace")
+ );
+ } catch (e) {
+ const msg =
+ (e.response && e.response.data && e.response.data.message) ||
+ e.message ||
+ this.$t("tools.message_blocklist.import_failed");
+ ToastUtils.error(msg);
+ }
+ },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index 8afdff87..d904ff2e 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -73,6 +73,12 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<div class="tool-card__title">{{ tool.title }}</div>
+ <span
+ v-if="tool.beta"
+ class="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-sm border border-amber-200 dark:border-amber-800"
+ >
+ {{ $t("tools.beta_badge") }}
+ </span>
<span
v-if="tool.comingSoon"
class="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-sm border border-gray-200 dark:border-zinc-700"
@@ -222,6 +228,15 @@ export default {
titleKey: "tools.sieve_filters.title",
descriptionKey: "tools.sieve_filters.description",
},
+ {
+ name: "message-blocklist",
+ route: { name: "message-blocklist" },
+ icon: "shield-alert",
+ iconBg: "tool-card__icon bg-rose-50 text-rose-600 dark:bg-rose-900/30 dark:text-rose-200",
+ titleKey: "tools.message_blocklist.title",
+ descriptionKey: "tools.message_blocklist.description",
+ beta: true,
+ },
{
name: "documentation",
route: { name: "documentation" },

diff --git a/meshchatx/src/frontend/js/apiClient.js b/meshchatx/src/frontend/js/apiClient.js
index 46b80836..46097534 100644
--- a/meshchatx/src/frontend/js/apiClient.js
+++ b/meshchatx/src/frontend/js/apiClient.js
@@ -2,6 +2,8 @@
* Axios-shaped HTTP helpers backed by fetch (same-origin API calls).
*/
+import { getCsrfToken } from "./csrfToken.js";
+
export function isCancel(error) {
if (!error) return false;
return error.name === "AbortError" || error.name === "CanceledError";
@@ -72,6 +74,12 @@ export function createApiClient(options = {}) {
const { params, data, signal, headers = {}, responseType } = config;
const url = buildUrl(path, params);
const hdrs = new Headers(headers);
+ if (method !== "GET" && method !== "HEAD" && path.startsWith("/api/")) {
+ const csrf = getCsrfToken();
+ if (csrf) {
+ hdrs.set("X-CSRF-Token", csrf);
+ }
+ }
const init = { method, signal, headers: hdrs };
if (data !== undefined && method !== "GET" && method !== "HEAD") {

diff --git a/meshchatx/src/frontend/js/browserLayoutStore.js b/meshchatx/src/frontend/js/browserLayoutStore.js
index 2deace37..739e292a 100644
--- a/meshchatx/src/frontend/js/browserLayoutStore.js
+++ b/meshchatx/src/frontend/js/browserLayoutStore.js
@@ -1,4 +1,5 @@
const NOMAD_TABS_KEY = "meshchatx.nomadnet.tabs";
+const MAP_TABS_KEY = "meshchatx.map.tabs";
const MESSAGE_PANES_KEY = "meshchatx.messages.panes";
const RNSH_LAYOUT_KEY = "meshchatx.rnsh.layout";
@@ -62,6 +63,28 @@ export function saveNomadTabs(state) {
writeJson(NOMAD_TABS_KEY, state);
}
+/**
+ * Load the persisted Map browser tab layout.
+ *
+ * @returns {{tabs: Array, activeIndex: number}|null} saved layout or null
+ */
+export function loadMapTabs() {
+ const data = readJson(MAP_TABS_KEY);
+ if (!data || !Array.isArray(data.tabs)) {
+ return null;
+ }
+ return data;
+}
+
+/**
+ * Persist the Map browser tab layout.
+ *
+ * @param {{tabs: Array, activeIndex: number}} state layout to save
+ */
+export function saveMapTabs(state) {
+ writeJson(MAP_TABS_KEY, state);
+}
+
/**
* Load the persisted Messages pane layout.
*

diff --git a/meshchatx/src/frontend/js/csrfToken.js b/meshchatx/src/frontend/js/csrfToken.js
new file mode 100644
index 00000000..03a752c9
--- /dev/null
+++ b/meshchatx/src/frontend/js/csrfToken.js
@@ -0,0 +1,19 @@
+let csrfToken = null;
+
+export function getCsrfToken() {
+ return csrfToken;
+}
+
+export function setCsrfToken(token) {
+ csrfToken = token || null;
+}
+
+export function clearCsrfToken() {
+ csrfToken = null;
+}
+
+export async function fetchCsrfToken(api) {
+ const response = await api.get("/api/v1/auth/csrf");
+ setCsrfToken(response.data?.csrf_token);
+ return csrfToken;
+}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 86be304d..c92357d3 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1168,7 +1168,11 @@
"drop_geo_files": "Kartendatei hier ablegen",
"drop_map_files_hint": "GeoJSON, KML, KMZ oder MBTiles",
"drop_no_geo_files": "Keine GeoJSON-, KML- oder KMZ-Dateien erkannt.",
- "drop_no_supported_files": "Keine GeoJSON-, KML-, KMZ- oder MBTiles-Dateien erkannt."
+ "drop_no_supported_files": "Keine GeoJSON-, KML-, KMZ- oder MBTiles-Dateien erkannt.",
+ "new_tab": "Neue Karte",
+ "new_tab_shortcut": "Neue Karte (Strg+T)",
+ "tab_default_name": "Karte {number}",
+ "tab_rename_hint": "Doppelklicken oder doppelt tippen zum Umbenennen"
},
"interface": {
"disable": "Deaktivieren",
@@ -1638,6 +1642,7 @@
"power_tools": "Dienstprogramme",
"diagnostics_description": "Mesh- und Reticulum-Werkzeuge an einem Ort: Erreichbarkeit, Pfade, Dateitransfer, Übersetzung, Dokumentation und Hardware-Hilfen. Öffnen Sie eine Zeile für die vollständige Ansicht.",
"back_to_tools": "Zurück zu Werkzeugen",
+ "beta_badge": "Beta",
"ping": {
"title": "Ping",
"description": "Laufzeit bis zu einem Ziel, das Ping akzeptiert."
@@ -1753,6 +1758,45 @@
"flow_folder": "Ordner",
"flow_no_rules": "Keine Regeln"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "Repository-Server",
"description": "Verteilen Sie MeshChatX- und Reticulum-bezogene Python-Wheels sowie eigene Dateien über einfaches HTTP, damit andere im Netz oder auf diesem Gerät sie herunterladen können.",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 6bf4488a..4af4aa3c 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -141,6 +141,27 @@
"privacy_data": "Data & device",
"privacy_subsection_device": "This device",
"privacy_subsection_telemetry": "Mesh telemetry",
+ "privacy_mode_enabled": "Privacy mode (block external HTTP/HTTPS)",
+ "privacy_mode_description": "Blocks map tiles, geocoding, LibreTranslate, firmware downloads, and other outbound HTTP/HTTPS from the app. The browser Content Security Policy is tightened to same-origin only.",
+ "web_exposure_title": "Network exposure",
+ "web_exposure_description": "The web UI bind address is set at startup via --host or MESHCHAT_HOST. Restrict who can reach it when not bound to loopback.",
+ "web_listen_address": "Current bind address",
+ "web_listen_https": "HTTPS",
+ "web_exposure_warning_title": "This instance is reachable beyond localhost",
+ "web_exposure_warning_body": "Binding to a non-loopback address exposes the web UI on your network. Enable authentication, restrict the port with a firewall, and prefer access over a VPN you control.",
+ "web_exposure_check_auth": "Authentication enabled",
+ "web_exposure_check_auth_off": "Authentication is disabled",
+ "web_exposure_check_firewall": "I restricted port access with a firewall or bind rules",
+ "web_exposure_check_vpn": "Remote access is only via a VPN I control",
+ "web_ui_ip_allowlist": "Web UI IP allowlist",
+ "web_ui_ip_allowlist_description": "Optional comma-separated IPs or CIDR ranges (for example 127.0.0.1/32, 192.168.1.0/24). Empty allows all clients.",
+ "web_ui_ip_allowlist_placeholder": "127.0.0.1/32, ::1/128, 192.168.0.0/16",
+ "landlock_status": "Landlock sandbox (Linux)",
+ "landlock_active": "Active",
+ "landlock_inactive": "Not active",
+ "landlock_auto_enabled": "Enabled automatically on this Linux kernel",
+ "landlock_kernel_unsupported": "Kernel does not support Landlock (5.13+ required)",
+ "landlock_disabled_by_env": "Disabled via MESHCHAT_LANDLOCK=0",
"settings_map_eyebrow": "Map",
"messages_description": "Configure how MeshChat handles message delivery failures. Control automatic retry behavior, attachment retransmission, and fallback mechanisms to ensure reliable message delivery across the mesh network.",
"auto_resend_title": "Auto resend when peer announces",
@@ -1116,7 +1137,11 @@
"drop_geo_files": "Drop map file here",
"drop_map_files_hint": "GeoJSON, KML, KMZ, or MBTiles",
"drop_no_geo_files": "No GeoJSON, KML, or KMZ files detected.",
- "drop_no_supported_files": "No GeoJSON, KML, KMZ, or MBTiles files detected."
+ "drop_no_supported_files": "No GeoJSON, KML, KMZ, or MBTiles files detected.",
+ "new_tab": "New map",
+ "new_tab_shortcut": "New map (Ctrl+T)",
+ "tab_default_name": "Map {number}",
+ "tab_rename_hint": "Double-click or double-tap to rename"
},
"interface": {
"disable": "Disable",
@@ -1727,6 +1752,7 @@
"power_tools": "Utilities",
"diagnostics_description": "Mesh and Reticulum utilities in one place: reachability, paths, file transfer, translation, documentation, and hardware helpers. Open any row for the full tool.",
"back_to_tools": "Back to tools",
+ "beta_badge": "Beta",
"ping": {
"title": "Ping",
"description": "Measure round-trip time to another node or app that answers ping."
@@ -1842,6 +1868,45 @@
"flow_folder": "Folder",
"flow_no_rules": "No rules"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "Repository server",
"description": "Redistribute MeshChatX and Reticulum-related Python wheels plus your own files over plain HTTP so people on your network—or on this device—can download them.",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 9f17544d..f76a49bf 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1116,7 +1116,11 @@
"drop_geo_files": "Suelta el archivo del mapa aquí",
"drop_map_files_hint": "GeoJSON, KML, KMZ o MBTiles",
"drop_no_geo_files": "No se detectaron archivos GeoJSON, KML o KMZ.",
- "drop_no_supported_files": "No se detectaron archivos GeoJSON, KML, KMZ o MBTiles."
+ "drop_no_supported_files": "No se detectaron archivos GeoJSON, KML, KMZ o MBTiles.",
+ "new_tab": "Mapa nuevo",
+ "new_tab_shortcut": "Mapa nuevo (Ctrl+T)",
+ "tab_default_name": "Mapa {number}",
+ "tab_rename_hint": "Doble clic o doble toque para renombrar"
},
"interface": {
"disable": "Inhabilitación",
@@ -1727,6 +1731,7 @@
"power_tools": "Utilidades",
"diagnostics_description": "Utilidades de malla y Reticulum en un mismo sitio: alcance, rutas, transferencia de archivos, traducción, documentación y ayudas de hardware. Abra una fila para la herramienta completa.",
"back_to_tools": "Volver a herramientas",
+ "beta_badge": "Beta",
"ping": {
"title": "Ping",
"description": "Medir el tiempo de ida y vuelta a un destino que acepta ping."
@@ -1842,6 +1847,45 @@
"flow_folder": "Carpeta",
"flow_no_rules": "Sin reglas"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "Servidor de repositorio",
"description": "Redistribuya ruedas Python de MeshChatX y del ecosistema Reticulum, y sus propios archivos, por HTTP sin cifrar para que otros en su red o en este equipo puedan descargarlos.",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 205b79ad..eb9b9913 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1116,7 +1116,11 @@
"drop_geo_files": "Pudota karttatiedosto tähän",
"drop_map_files_hint": "GeoJSON, KML, KMZ tai MBTiles",
"drop_no_geo_files": "GeoJSON, KML, tai KMZ -tiedostoa ei havaittu.",
- "drop_no_supported_files": "GeoJSON-, KML-, KMZ- tai MBTiles-tiedostoa ei havaittu."
+ "drop_no_supported_files": "GeoJSON-, KML-, KMZ- tai MBTiles-tiedostoa ei havaittu.",
+ "new_tab": "New map",
+ "new_tab_shortcut": "New map (Ctrl+T)",
+ "tab_default_name": "Map {number}",
+ "tab_rename_hint": "Double-click or double-tap to rename"
},
"interface": {
"disable": "Disable",
@@ -1727,6 +1731,7 @@
"power_tools": "Utilities",
"diagnostics_description": "Mesh and Reticulum utilities in one place: reachability, paths, file transfer, translation, documentation, and hardware helpers. Open any row for the full tool.",
"back_to_tools": "Back to tools",
+ "beta_badge": "Beta",
"ping": {
"title": "Ping",
"description": "Measure round-trip time to another node or app that answers ping."
@@ -1842,6 +1847,45 @@
"flow_folder": "Folder",
"flow_no_rules": "No rules"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "Repository server",
"description": "Redistribute MeshChatX and Reticulum-related Python wheels plus your own files over plain HTTP so people on your network—or on this device—can download them.",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index e5161ee6..c25cf722 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1116,7 +1116,11 @@
"drop_geo_files": "Déposez le fichier carte ici",
"drop_map_files_hint": "GeoJSON, KML, KMZ ou MBTiles",
"drop_no_geo_files": "Aucun fichier GeoJSON, KML ou KMZ détecté.",
- "drop_no_supported_files": "Aucun fichier GeoJSON, KML, KMZ ou MBTiles détecté."
+ "drop_no_supported_files": "Aucun fichier GeoJSON, KML, KMZ ou MBTiles détecté.",
+ "new_tab": "Nouvelle carte",
+ "new_tab_shortcut": "Nouvelle carte (Ctrl+T)",
+ "tab_default_name": "Carte {number}",
+ "tab_rename_hint": "Double-cliquez ou double-tapez pour renommer"
},
"interface": {
"disable": "Désactiver",
@@ -1727,6 +1731,7 @@
"power_tools": "Services publics",
"diagnostics_description": "Utilitaires mesh et Reticulum au même endroit : joignabilité, chemins, transfert de fichiers, traduction, documentation et aides matérielles. Ouvrez une ligne pour l'outil complet.",
"back_to_tools": "Retour aux outils",
+ "beta_badge": "Beta",
"ping": {
"title": "Ping",
"description": "Mesurer le temps aller-retour vers une destination qui accepte ping."
@@ -1842,6 +1847,45 @@
"flow_folder": "Dossier",
"flow_no_rules": "Aucune règle"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "Serveur de dépôt",
"description": "Redistribuez les wheels Python MeshChatX et de l'écosystème Reticulum, ainsi que vos propres fichiers, en HTTP clair pour que d'autres sur votre réseau ou sur cet appareil puissent les télécharger.",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 0830c849..b426d13e 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1168,7 +1168,11 @@
"drop_geo_files": "Rilascia il file della mappa qui",
"drop_map_files_hint": "GeoJSON, KML, KMZ o MBTiles",
"drop_no_geo_files": "Nessun file GeoJSON, KML o KMZ rilevato.",
- "drop_no_supported_files": "Nessun file GeoJSON, KML, KMZ o MBTiles rilevato."
+ "drop_no_supported_files": "Nessun file GeoJSON, KML, KMZ o MBTiles rilevato.",
+ "new_tab": "Nuova mappa",
+ "new_tab_shortcut": "Nuova mappa (Ctrl+T)",
+ "tab_default_name": "Mappa {number}",
+ "tab_rename_hint": "Doppio clic o doppio tocco per rinominare"
},
"interface": {
"disable": "Disabilita",
@@ -1779,6 +1783,7 @@
"power_tools": "Utilità",
"diagnostics_description": "Strumenti mesh e Reticulum in un unico posto: raggiungibilità, percorsi, trasferimento file, traduzione, documentazione e assistenza hardware. Apri una riga per lo strumento completo.",
"back_to_tools": "Torna agli strumenti",
+ "beta_badge": "Beta",
"ping": {
"title": "Ping",
"description": "Misura il tempo di andata e ritorno verso una destinazione che accetta il ping."
@@ -1894,6 +1899,45 @@
"flow_folder": "Cartella",
"flow_no_rules": "Nessuna regola"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "Server repository",
"description": "Ridistribuisci le wheel Python di MeshChatX e dell'ecosistema Reticulum, più i tuoi file, su HTTP in chiaro così altri sulla tua rete o su questo dispositivo possono scaricarli.",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 2b860f04..22d6d381 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1116,7 +1116,11 @@
"drop_geo_files": "Sleep kaartbestand hierheen",
"drop_map_files_hint": "GeoJSON, KML, KMZ of MBTiles",
"drop_no_geo_files": "Geen GeoJSON-, KML- of KMZ-bestanden gedetecteerd.",
- "drop_no_supported_files": "Geen GeoJSON-, KML-, KMZ- of MBTiles-bestanden gedetecteerd."
+ "drop_no_supported_files": "Geen GeoJSON-, KML-, KMZ- of MBTiles-bestanden gedetecteerd.",
+ "new_tab": "Nieuwe kaart",
+ "new_tab_shortcut": "Nieuwe kaart (Ctrl+T)",
+ "tab_default_name": "Kaart {number}",
+ "tab_rename_hint": "Dubbelklikken of dubbel tikken om te hernoemen"
},
"interface": {
"disable": "Uitschakelen",
@@ -1727,6 +1731,7 @@
"power_tools": "Hulpmiddelen",
"diagnostics_description": "Mesh- en Reticulumhulpprogramma's op één plek: bereikbaarheid, paden, bestandsoverdracht, vertaling, documentatie en hardwarehulpen. Open een rij voor de volledige tool.",
"back_to_tools": "Terug naar gereedschap",
+ "beta_badge": "Beta",
"ping": {
"title": "Ping",
"description": "Meet de rondreistijd naar een bestemming die ping accepteert."
@@ -1842,6 +1847,45 @@
"flow_folder": "Map",
"flow_no_rules": "Geen regels"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "Repositoryserver",
"description": "Distribueer MeshChatX- en Reticulum-gerelateerde Python wheels plus uw eigen bestanden opnieuw via plat HTTP zodat anderen op uw netwerk of op dit apparaat ze kunnen downloaden.",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index dc2a5931..b96d6912 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1168,7 +1168,11 @@
"drop_geo_files": "Перетащите файл карты сюда",
"drop_map_files_hint": "GeoJSON, KML, KMZ или MBTiles",
"drop_no_geo_files": "Не обнаружено файлов GeoJSON, KML или KMZ.",
- "drop_no_supported_files": "Не обнаружено файлов GeoJSON, KML, KMZ или MBTiles."
+ "drop_no_supported_files": "Не обнаружено файлов GeoJSON, KML, KMZ или MBTiles.",
+ "new_tab": "Новая карта",
+ "new_tab_shortcut": "Новая карта (Ctrl+T)",
+ "tab_default_name": "Карта {number}",
+ "tab_rename_hint": "Двойной щелчок или двойное касание для переименования"
},
"interface": {
"disable": "Выключить",
@@ -1638,6 +1642,7 @@
"power_tools": "Утилиты",
"diagnostics_description": "Сетевые и Reticulum-утилиты в одном месте: доступность, маршруты, передача файлов, перевод, документация и помощь с оборудованием. Откройте строку для полного инструмента.",
"back_to_tools": "Назад к инструментам",
+ "beta_badge": "Beta",
"ping": {
"title": "Ping",
"description": "Измерение RTT до узла, который принимает ping."
@@ -1753,6 +1758,45 @@
"flow_folder": "Папка",
"flow_no_rules": "Нет правил"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "Сервер репозитория",
"description": "Повторно распространяйте Python-колёса MeshChatX и экосистемы Reticulum плюс свои файлы по обычному HTTP, чтобы другие в сети или на этом устройстве могли их скачать.",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 8466f81e..f8bfcaec 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1116,7 +1116,11 @@
"drop_geo_files": "将地图文件拖放到此处",
"drop_map_files_hint": "GeoJSON、KML、KMZ 或 MBTiles",
"drop_no_geo_files": "未检测到 GeoJSON、KML 或 KMZ 文件。",
- "drop_no_supported_files": "未检测到 GeoJSON、KML、KMZ 或 MBTiles 文件。"
+ "drop_no_supported_files": "未检测到 GeoJSON、KML、KMZ 或 MBTiles 文件。",
+ "new_tab": "新地图",
+ "new_tab_shortcut": "新地图 (Ctrl+T)",
+ "tab_default_name": "地图 {number}",
+ "tab_rename_hint": "双击或双触重命名"
},
"interface": {
"disable": "禁用",
@@ -1727,6 +1731,7 @@
"power_tools": "公用事业",
"diagnostics_description": "网格与 Reticulum 常用工具集中在一处:可达性、路径、文件传输、翻译、文档与硬件辅助。点击任一行打开完整工具。",
"back_to_tools": "返回工具",
+ "beta_badge": "Beta",
"ping": {
"title": "ing",
"description": "测量到一个接受ping的目的地的往返时间。"
@@ -1842,6 +1847,45 @@
"flow_folder": "文件夹",
"flow_no_rules": "无规则"
},
+ "message_blocklist": {
+ "title": "Message blocklist",
+ "description": "Auto-banish senders when their message or name matches a word or regex from your shared blocklist.",
+ "beta_notice_title": "Beta (work in progress)",
+ "beta_notice_body": "This tool is off until you turn it on. Words match regardless of case. Regex uses normal patterns. If something matches, the sender gets banished right away. Export a list to share with your group, or import one to merge or replace yours.",
+ "enable_label": "Enable message blocklist",
+ "enable_hint": "When disabled, entries are saved but no automatic banish runs.",
+ "entries_heading": "Blocked phrases",
+ "add_entry": "Add phrase",
+ "empty_entries": "No phrases yet. Add entries or import a shared list.",
+ "entry_enabled": "Active",
+ "entry_placeholder": "spam phrase or regex pattern",
+ "remove_entry": "Remove",
+ "scope_label": "Applies to",
+ "scope_everyone": "Everyone",
+ "scope_contacts": "Contacts only",
+ "scope_non_contacts": "Non-contacts only",
+ "match_in_label": "Match in",
+ "match_message": "Message title and body",
+ "match_peer_fields": "Peer display name and contact fields",
+ "match_mode_substring": "Word",
+ "match_mode_regex": "Regex",
+ "export": "Export",
+ "import": "Import",
+ "save": "Save",
+ "saving": "Saving…",
+ "revert": "Reload",
+ "saved": "Blocklist saved",
+ "save_failed": "Could not save blocklist",
+ "load_failed": "Could not load blocklist",
+ "enabled_toast": "Message blocklist enabled",
+ "disabled_toast": "Message blocklist disabled",
+ "exported": "Blocklist exported",
+ "export_failed": "Could not export blocklist",
+ "import_merge_confirm": "Merge with your current list? Choose Cancel to replace your list entirely.",
+ "imported_merge": "Blocklist merged",
+ "imported_replace": "Blocklist replaced",
+ "import_failed": "Could not import blocklist"
+ },
"repository_server": {
"title": "软件仓库服务器",
"description": "通过明文 HTTP 重新分发 MeshChatX 与 Reticulum 相关 Python 安装包,以及您自己的文件,供局域网或本机下载。",

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index a8b3c664..aa9003b7 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -13,6 +13,7 @@ import "@mdi/font/css/materialdesignicons.css";
import "./fonts/RobotoMonoNerdFont/font.css";
import { startCodec2ScriptsBackgroundLoad } from "./js/Codec2Loader";
import { createApiClient } from "./js/apiClient.js";
+import { fetchCsrfToken } from "./js/csrfToken.js";
import "./js/HeapMonitor.js";
import App from "./components/App.vue";
@@ -93,7 +94,8 @@ const router = createRouter({
{
name: "map",
path: "/map",
- component: defineAsyncComponent(() => import("./components/map/MapPage.vue")),
+ meta: { keepAlive: true },
+ component: defineAsyncComponent(() => import("./components/map/MapBrowser.vue")),
},
{
name: "map-popout",
@@ -259,6 +261,11 @@ const router = createRouter({
path: "/tools/sieve-filters",
component: defineAsyncComponent(() => import("./components/tools/SieveFiltersPage.vue")),
},
+ {
+ name: "message-blocklist",
+ path: "/tools/message-blocklist",
+ component: defineAsyncComponent(() => import("./components/tools/MessageBlocklistPage.vue")),
+ },
{
name: "rnode-flasher",
path: "/tools/rnode-flasher",
@@ -309,6 +316,12 @@ window.api = createApiClient({
},
});
+try {
+ await fetchCsrfToken(window.api);
+} catch {
+ // CSRF token will be retried on the next mutating request if needed.
+}
+
router.beforeEach(async (to, from, next) => {
try {
const response = await window.api.get("/api/v1/auth/status");

diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index 405116cb..ec0c1f3e 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -20,6 +20,7 @@ from meshchatx.src.backend.database.schema import DatabaseSchema
# in restricted environments like sandboxes.
os.environ["MESHCHAT_LOG_DIR"] = tempfile.mkdtemp()
os.environ["MESHCHAT_SKIP_STORAGE_LOCK"] = "1"
+os.environ["MESHCHAT_DISABLE_CSRF"] = "1"
@pytest.fixture(scope="session")
@@ -250,3 +251,17 @@ def mock_app(db, tmp_path, temp_db):
yield app
app.teardown_identity()
+
+
+async def fetch_api_csrf_headers(client):
+ response = await client.get("/api/v1/auth/csrf")
+ assert response.status == 200
+ payload = await response.json()
+ token = payload.get("csrf_token")
+ assert token
+ return {"X-CSRF-Token": token}
+
+
+def extend_meshchat_middlewares(aio_app, middlewares):
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = middlewares
+ aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])

diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index e1c6a52d..e725bed3 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -480,6 +480,22 @@
"method": "PUT",
"path": "/api/v1/lxmf/sieve-filters"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/message-blocklist/import"
+ },
{
"method": "DELETE",
"path": "/api/v1/maintenance/announces"

diff --git a/tests/backend/test_access_attempts_enforcement.py b/tests/backend/test_access_attempts_enforcement.py
index 8b76559e..fd1ef4eb 100644
--- a/tests/backend/test_access_attempts_enforcement.py
+++ b/tests/backend/test_access_attempts_enforcement.py
@@ -231,10 +231,10 @@ def test_enforce_untrusted_monotone_hypothesis(mock_app, n):
def _make_aio_app(mock_app, use_https: bool):
mock_app.session_secret_key = secrets.token_urlsafe(32)
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = mock_app._define_routes(routes)
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = mock_app._define_routes(routes)
aio_app = web.Application()
setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https))
- aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw])
+ aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_app_security_features.py b/tests/backend/test_app_security_features.py
new file mode 100644
index 00000000..b3f078a0
--- /dev/null
+++ b/tests/backend/test_app_security_features.py
@@ -0,0 +1,153 @@
+# SPDX-License-Identifier: 0BSD
+
+import secrets
+
+import bcrypt
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+from aiohttp_session import setup as setup_session
+
+from meshchatx.src.backend.app_security_settings import save_app_security_settings
+from meshchatx.src.backend.ip_allowlist import (
+ client_ip_allowed,
+ parse_allowlist_networks,
+)
+from meshchatx.src.backend.privacy_mode import (
+ OutboundHttpBlockedError,
+ privacy_mode_enabled,
+)
+from tests.backend.conftest import extend_meshchat_middlewares, fetch_api_csrf_headers
+
+
+def _make_aio_app(mock_app, use_https: bool):
+ mock_app.session_secret_key = secrets.token_urlsafe(32)
+ mock_app.listen_host = "127.0.0.1"
+ mock_app.listen_port = 8000
+ mock_app.use_https = use_https
+ mock_app.landlock_active = False
+ routes = web.RouteTableDef()
+ middlewares = mock_app._define_routes(routes)
+ aio_app = web.Application()
+ setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https))
+ extend_meshchat_middlewares(aio_app, middlewares)
+ aio_app.add_routes(routes)
+ return aio_app
+
+
+def test_ip_allowlist_parsing():
+ nets = parse_allowlist_networks("127.0.0.1/32, 192.168.1.0/24")
+ assert len(nets) == 2
+ assert client_ip_allowed("127.0.0.1", "127.0.0.1/32")
+ assert not client_ip_allowed("10.0.0.5", "127.0.0.1/32")
+ assert client_ip_allowed("192.168.1.50", "192.168.1.0/24")
+
+
+def test_ip_allowlist_empty_allows_all():
+ assert client_ip_allowed("203.0.113.1", "")
+
+
+def test_app_security_settings_roundtrip(tmp_path):
+ saved = save_app_security_settings(
+ str(tmp_path),
+ {"web_ui_ip_allowlist": "127.0.0.1/32, ::1/128"},
+ )
+ assert saved["web_ui_ip_allowlist"] == "127.0.0.1/32, ::1/128"
+
+
+def test_privacy_mode_default_off(mock_app):
+ assert privacy_mode_enabled(mock_app.config) is False
+
+
+def test_privacy_mode_blocks_outbound(mock_app):
+ mock_app.config.privacy_mode_enabled.set(True)
+ with pytest.raises(OutboundHttpBlockedError):
+ mock_app._require_outbound_http("test")
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
+async def test_ip_allowlist_middleware_blocks_api(mock_app):
+ save_app_security_settings(
+ mock_app.storage_dir,
+ {"web_ui_ip_allowlist": "192.168.50.0/24"},
+ )
+ aio_app = _make_aio_app(mock_app, use_https=False)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.get("/api/v1/config")
+ assert r.status == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
+async def test_csrf_required_for_config_patch(mock_app, monkeypatch):
+ monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False)
+ aio_app = _make_aio_app(mock_app, use_https=False)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ blocked = await client.patch(
+ "/api/v1/server/security", json={"web_ui_ip_allowlist": ""}
+ )
+ assert blocked.status == 403
+ headers = await fetch_api_csrf_headers(client)
+ ok = await client.patch(
+ "/api/v1/server/security",
+ json={"web_ui_ip_allowlist": ""},
+ headers=headers,
+ )
+ assert ok.status == 200
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
+async def test_login_requires_csrf(mock_app, monkeypatch):
+ monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False)
+ mock_app.config.auth_enabled.set(True)
+ pw = b"csrf-login-password"
+ mock_app.config.auth_password_hash.set(
+ bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8"),
+ )
+ aio_app = _make_aio_app(mock_app, use_https=False)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ denied = await client.post(
+ "/api/v1/auth/login", json={"password": pw.decode("utf-8")}
+ )
+ assert denied.status == 403
+ headers = await fetch_api_csrf_headers(client)
+ login = await client.post(
+ "/api/v1/auth/login",
+ json={"password": pw.decode("utf-8")},
+ headers=headers,
+ )
+ assert login.status == 200
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
+async def test_server_security_endpoint(mock_app):
+ aio_app = _make_aio_app(mock_app, use_https=False)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.get("/api/v1/server/security")
+ assert r.status == 200
+ body = await r.json()
+ assert body["listen_host"] == "127.0.0.1"
+ assert body["is_loopback_bind"] is True
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
+async def test_privacy_mode_blocks_map_export(mock_app):
+ mock_app.config.privacy_mode_enabled.set(True)
+ aio_app = _make_aio_app(mock_app, use_https=False)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ headers = await fetch_api_csrf_headers(client)
+ r = await client.post(
+ "/api/v1/map/export",
+ json={"bbox": [0, 0, 1, 1], "min_zoom": 0, "max_zoom": 1},
+ headers=headers,
+ )
+ assert r.status == 403

diff --git a/tests/backend/test_csp_logic.py b/tests/backend/test_csp_logic.py
index 2985acec..55eac4a5 100644
--- a/tests/backend/test_csp_logic.py
+++ b/tests/backend/test_csp_logic.py
@@ -59,7 +59,7 @@ async def test_csp_header_logic(mock_rns_minimal, tmp_path):
# Call _define_routes to get the security_middleware
routes = web.RouteTableDef()
- _, _, security_middleware = app_instance._define_routes(routes)
+ _, _, security_middleware, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@@ -94,7 +94,7 @@ async def test_security_middleware_sets_cors_headers_on_rnode_flasher(
return web.Response(text="// module")
routes = web.RouteTableDef()
- _, _, security_middleware = app_instance._define_routes(routes)
+ _, _, security_middleware, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@@ -127,7 +127,7 @@ async def test_security_middleware_does_not_set_cors_on_reticulum_docs(
return web.Response(text="<html></html>")
routes = web.RouteTableDef()
- _, _, security_middleware = app_instance._define_routes(routes)
+ _, _, security_middleware, _, _ = app_instance._define_routes(routes)
response = await security_middleware(request, mock_handler)
@@ -187,3 +187,37 @@ async def test_config_update_csp(mock_rns_minimal, tmp_path):
== "https://api1.com, https://api2.com"
)
assert app_instance.config.csp_extra_img_src.get() == "https://img.com"
+
+
+@pytest.mark.asyncio
+async def test_csp_privacy_mode_strips_external_sources(mock_rns_minimal, tmp_path):
+ storage_dir = str(tmp_path / "storage")
+ config_dir = str(tmp_path / "config")
+
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app_instance = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=storage_dir,
+ reticulum_config_dir=config_dir,
+ )
+ app_instance.config.privacy_mode_enabled.set(True)
+ app_instance.config.csp_extra_connect_src.set("https://api.example.com")
+ app_instance.config.map_tile_server_url.set(
+ "https://tiles.example.com/{z}/{x}/{y}.png",
+ )
+
+ request = MagicMock(spec=web.Request)
+ request.path = "/"
+ request.app = {}
+
+ async def mock_handler(req):
+ return web.Response(text="test")
+
+ routes = web.RouteTableDef()
+ _, _, security_middleware, _, _ = app_instance._define_routes(routes)
+ response = await security_middleware(request, mock_handler)
+ csp = response.headers.get("Content-Security-Policy", "")
+ assert "openstreetmap.org" not in csp
+ assert "api.example.com" not in csp
+ assert "tiles.example.com" not in csp
+ assert "connect-src 'self'" in csp

diff --git a/tests/backend/test_http_auth_security.py b/tests/backend/test_http_auth_security.py
index cc18e37e..7ccee213 100644
--- a/tests/backend/test_http_auth_security.py
+++ b/tests/backend/test_http_auth_security.py
@@ -12,15 +12,20 @@ from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.config_manager import ConfigManager
+from tests.backend.conftest import extend_meshchat_middlewares, fetch_api_csrf_headers
def _make_aio_app(mock_app, use_https: bool):
mock_app.session_secret_key = secrets.token_urlsafe(32)
+ mock_app.listen_host = "127.0.0.1"
+ mock_app.listen_port = 8000
+ mock_app.use_https = use_https
+ mock_app.landlock_active = False
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = mock_app._define_routes(routes)
+ middlewares = mock_app._define_routes(routes)
aio_app = web.Application()
setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https))
- aio_app.middlewares.extend([auth_mw, mime_mw, sec_mw])
+ extend_meshchat_middlewares(aio_app, middlewares)
aio_app.add_routes(routes)
return aio_app
@@ -63,9 +68,11 @@ async def test_login_sets_cookie_and_allows_protected_api(mock_app):
aio_app = _make_aio_app(mock_app, use_https=False)
async with TestClient(TestServer(aio_app)) as client:
+ headers = await fetch_api_csrf_headers(client)
login = await client.post(
"/api/v1/auth/login",
json={"password": pw.decode("utf-8")},
+ headers=headers,
)
assert login.status == 200
set_cookie = login.headers.get("Set-Cookie", "")
@@ -104,10 +111,16 @@ async def test_logout_clears_session_for_protected_api(mock_app):
aio_app = _make_aio_app(mock_app, use_https=False)
async with TestClient(TestServer(aio_app)) as client:
- await client.post("/api/v1/auth/login", json={"password": pw.decode("utf-8")})
+ headers = await fetch_api_csrf_headers(client)
+ await client.post(
+ "/api/v1/auth/login",
+ json={"password": pw.decode("utf-8")},
+ headers=headers,
+ )
assert (await client.get("/api/v1/database/backups")).status == 200
- out = await client.post("/api/v1/auth/logout")
+ headers = await fetch_api_csrf_headers(client)
+ out = await client.post("/api/v1/auth/logout", headers=headers)
assert out.status == 200
assert (await client.get("/api/v1/database/backups")).status == 401
@@ -123,10 +136,11 @@ async def test_auth_login_invalid_json_returns_400(mock_app):
aio_app = _make_aio_app(mock_app, use_https=False)
async with TestClient(TestServer(aio_app)) as client:
+ headers = await fetch_api_csrf_headers(client)
r = await client.post(
"/api/v1/auth/login",
data="{not-json",
- headers={"Content-Type": "application/json"},
+ headers={**headers, "Content-Type": "application/json"},
)
assert r.status == 400
body = await r.json()
@@ -149,10 +163,11 @@ def test_auth_login_fuzz_never_500(mock_app, body):
async def run():
async with TestClient(TestServer(aio_app)) as client:
+ headers = await fetch_api_csrf_headers(client)
r = await client.post(
"/api/v1/auth/login",
data=body,
- headers={"Content-Type": "application/json"},
+ headers={**headers, "Content-Type": "application/json"},
)
assert r.status != 500

diff --git a/tests/backend/test_identity_restore_http_api.py b/tests/backend/test_identity_restore_http_api.py
index fc5143b2..2f6dcd71 100644
--- a/tests/backend/test_identity_restore_http_api.py
+++ b/tests/backend/test_identity_restore_http_api.py
@@ -15,8 +15,8 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_identity_switch_http_api.py b/tests/backend/test_identity_switch_http_api.py
index 600efbea..3331398a 100644
--- a/tests/backend/test_identity_switch_http_api.py
+++ b/tests/backend/test_identity_switch_http_api.py
@@ -16,8 +16,8 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_landlock_sandbox.py b/tests/backend/test_landlock_sandbox.py
new file mode 100644
index 00000000..5dc9c7e8
--- /dev/null
+++ b/tests/backend/test_landlock_sandbox.py
@@ -0,0 +1,77 @@
+# SPDX-License-Identifier: 0BSD
+
+import sys
+from unittest.mock import patch
+
+import pytest
+
+from meshchatx.src.backend import landlock_sandbox as ll
+
+
+def test_parse_kernel_version():
+ assert ll._parse_kernel_version("6.12.7-1-cachyos-hardened") == (6, 12, 7)
+ assert ll._parse_kernel_version("5.13.0") == (5, 13, 0)
+ assert ll._parse_kernel_version("5.12.19") == (5, 12, 19)
+
+
+def test_kernel_version_meets_minimum():
+ with patch.object(
+ ll.os, "uname", return_value=type("U", (), {"release": "6.12.7"})()
+ ):
+ assert ll._kernel_version_meets_minimum() is True
+ with patch.object(
+ ll.os, "uname", return_value=type("U", (), {"release": "5.12.99"})()
+ ):
+ assert ll._kernel_version_meets_minimum() is False
+
+
+def test_landlock_requested_non_linux():
+ with patch.object(ll, "sys") as mock_sys:
+ mock_sys.platform = "darwin"
+ assert ll.landlock_requested() is False
+
+
+def test_landlock_requested_respects_disable_env(monkeypatch):
+ monkeypatch.setenv("MESHCHAT_LANDLOCK", "0")
+ with patch.object(ll, "sys") as mock_sys:
+ mock_sys.platform = "linux"
+ assert ll.landlock_requested() is False
+ assert ll.landlock_disabled_by_env() is True
+
+
+def test_landlock_requested_force_enable_env(monkeypatch):
+ monkeypatch.setenv("MESHCHAT_LANDLOCK", "1")
+ with patch.object(ll, "sys") as mock_sys:
+ mock_sys.platform = "linux"
+ assert ll.landlock_requested() is True
+ assert ll.landlock_auto_enabled() is False
+
+
+def test_landlock_auto_when_supported(monkeypatch):
+ monkeypatch.delenv("MESHCHAT_LANDLOCK", raising=False)
+ ll._landlock_support_cached = None
+ with (
+ patch.object(ll, "sys") as mock_sys,
+ patch.object(ll, "landlock_kernel_supported", return_value=True),
+ ):
+ mock_sys.platform = "linux"
+ assert ll.landlock_requested() is True
+ assert ll.landlock_auto_enabled() is True
+
+
+def test_landlock_auto_off_when_kernel_unsupported(monkeypatch):
+ monkeypatch.delenv("MESHCHAT_LANDLOCK", raising=False)
+ with (
+ patch.object(ll, "sys") as mock_sys,
+ patch.object(ll, "landlock_kernel_supported", return_value=False),
+ ):
+ mock_sys.platform = "linux"
+ assert ll.landlock_requested() is False
+ assert ll.landlock_auto_enabled() is False
+
+
+@pytest.mark.skipif(sys.platform != "linux", reason="Landlock probe requires Linux")
+def test_landlock_kernel_supported_on_linux():
+ ll._landlock_support_cached = None
+ supported = ll.landlock_kernel_supported()
+ assert isinstance(supported, bool)

diff --git a/tests/backend/test_media_http_api.py b/tests/backend/test_media_http_api.py
index 125419cc..1bbed6ab 100644
--- a/tests/backend/test_media_http_api.py
+++ b/tests/backend/test_media_http_api.py
@@ -17,8 +17,8 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_message_blocklist.py b/tests/backend/test_message_blocklist.py
new file mode 100644
index 00000000..ddc3b3cd
--- /dev/null
+++ b/tests/backend/test_message_blocklist.py
@@ -0,0 +1,129 @@
+# SPDX-License-Identifier: 0BSD
+
+from meshchatx.src.backend.message_blocklist import (
+ build_export_document,
+ first_matching_blocklist_entry,
+ normalize_message_blocklist,
+ parse_import_document,
+ parse_message_blocklist_json,
+)
+
+
+def test_parse_empty_defaults():
+ out = parse_message_blocklist_json(None)
+ assert out["scope"] == "non_contacts"
+ assert out["match_message"] is True
+ assert out["entries"] == []
+
+
+def test_normalize_rejects_invalid_regex():
+ raw = {
+ "entries": [{"text": "[invalid", "match_mode": "regex"}],
+ }
+ out = normalize_message_blocklist(raw)
+ assert out["entries"] == []
+
+
+def test_substring_match_message():
+ blocklist = normalize_message_blocklist(
+ {
+ "scope": "everyone",
+ "match_message": True,
+ "match_peer_fields": False,
+ "entries": [{"text": "buy now", "match_mode": "substring"}],
+ },
+ )
+ m = first_matching_blocklist_entry(
+ blocklist,
+ "alice",
+ is_contact=False,
+ message_haystack="click BUY NOW here",
+ )
+ assert m is not None
+ assert m["text"] == "buy now"
+
+
+def test_scope_non_contacts():
+ blocklist = normalize_message_blocklist(
+ {
+ "scope": "non_contacts",
+ "match_message": True,
+ "entries": [{"text": "spam"}],
+ },
+ )
+ assert (
+ first_matching_blocklist_entry(
+ blocklist,
+ "peer",
+ is_contact=True,
+ message_haystack="spam here",
+ )
+ is None
+ )
+ assert (
+ first_matching_blocklist_entry(
+ blocklist,
+ "peer",
+ is_contact=False,
+ message_haystack="spam here",
+ )
+ is not None
+ )
+
+
+def test_regex_match():
+ blocklist = normalize_message_blocklist(
+ {
+ "match_message": True,
+ "entries": [{"text": r"foo\d+", "match_mode": "regex"}],
+ },
+ )
+ assert (
+ first_matching_blocklist_entry(
+ blocklist,
+ "peer",
+ message_haystack="hello foo99",
+ )
+ is not None
+ )
+ assert (
+ first_matching_blocklist_entry(
+ blocklist,
+ "peer",
+ message_haystack="hello bar",
+ )
+ is None
+ )
+
+
+def test_export_and_import_roundtrip():
+ blocklist = normalize_message_blocklist(
+ {
+ "scope": "contacts",
+ "match_peer_fields": True,
+ "match_message": False,
+ "entries": [{"text": "viagra", "match_mode": "substring"}],
+ },
+ )
+ doc = build_export_document(blocklist)
+ imported = parse_import_document(doc, merge=False)
+ assert imported is not None
+ assert imported["scope"] == "contacts"
+ assert imported["entries"][0]["text"] == "viagra"
+
+
+def test_import_merge_dedupes():
+ existing = normalize_message_blocklist(
+ {"entries": [{"text": "alpha"}]},
+ )
+ doc = build_export_document(
+ normalize_message_blocklist({"entries": [{"text": "alpha"}, {"text": "beta"}]}),
+ )
+ merged = parse_import_document(doc, merge=True, existing=existing)
+ assert merged is not None
+ texts = [e["text"] for e in merged["entries"]]
+ assert texts == ["alpha", "beta"]
+
+
+def test_import_rejects_bad_schema():
+ assert parse_import_document({"schema": "other"}, merge=False) is None

diff --git a/tests/backend/test_notification_user_facing_filter.py b/tests/backend/test_notification_user_facing_filter.py
index 54a6d26a..416d5a16 100644
--- a/tests/backend/test_notification_user_facing_filter.py
+++ b/tests/backend/test_notification_user_facing_filter.py
@@ -439,8 +439,8 @@ pytestmark_integration = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_notifications.py b/tests/backend/test_notifications.py
index 11f32bf7..90bcf2b9 100644
--- a/tests/backend/test_notifications.py
+++ b/tests/backend/test_notifications.py
@@ -454,8 +454,8 @@ async def test_auth_middleware_returns_401_for_api_without_session_when_auth_ena
app.config.auth_enabled.set(True)
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
aio_app.add_routes(routes)
async with TestClient(TestServer(aio_app)) as client:

diff --git a/tests/backend/test_rnode_download_firmware.py b/tests/backend/test_rnode_download_firmware.py
index 04b625e0..ee1d2df4 100644
--- a/tests/backend/test_rnode_download_firmware.py
+++ b/tests/backend/test_rnode_download_firmware.py
@@ -16,8 +16,8 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/backend/test_telephone_audio_ws.py b/tests/backend/test_telephone_audio_ws.py
index fdaca2be..c601276a 100644
--- a/tests/backend/test_telephone_audio_ws.py
+++ b/tests/backend/test_telephone_audio_ws.py
@@ -12,8 +12,8 @@ pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
def _build_aio_app(app):
routes = web.RouteTableDef()
- auth_mw, mime_mw, sec_mw = app._define_routes(routes)
- aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
aio_app.add_routes(routes)
return aio_app

diff --git a/tests/frontend/MapBrowser.test.js b/tests/frontend/MapBrowser.test.js
new file mode 100644
index 00000000..66ad5621
--- /dev/null
+++ b/tests/frontend/MapBrowser.test.js
@@ -0,0 +1,210 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/js/TileCache", () => ({
+ default: {
+ getMapState: vi.fn().mockResolvedValue(null),
+ setMapState: vi.fn().mockResolvedValue(),
+ initPromise: Promise.resolve(),
+ },
+}));
+
+vi.mock("@/components/map/MapPage.vue", () => ({
+ default: {
+ name: "MapPage",
+ template: '<div class="map-page-stub" :data-storage-id="tabStorageId"></div>',
+ props: {
+ embedded: { type: Boolean, default: false },
+ tabStorageId: { type: String, default: "" },
+ tabTitle: { type: String, default: "" },
+ isActiveTab: { type: Boolean, default: true },
+ },
+ emits: ["update-title"],
+ },
+}));
+
+import TileCache from "@/js/TileCache";
+import MapBrowser from "@/components/map/MapBrowser.vue";
+
+const MaterialDesignIconStub = {
+ name: "MaterialDesignIcon",
+ template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
+ props: ["iconName"],
+};
+
+describe("MapBrowser.vue", () => {
+ const mountBrowser = async (route = { name: "map", params: {}, query: {} }) => {
+ const wrapper = mount(MapBrowser, {
+ global: {
+ mocks: {
+ $t: (key, params) => {
+ if (key === "map.tab_default_name") {
+ return `Map ${params?.number ?? ""}`.trim();
+ }
+ return key;
+ },
+ $route: { name: "map", ...route },
+ $router: { replace: vi.fn(() => Promise.resolve()) },
+ },
+ stubs: {
+ MaterialDesignIcon: MaterialDesignIconStub,
+ },
+ },
+ });
+ await flushPromises();
+ return wrapper;
+ };
+
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ });
+
+ it("creates a single tab on mount", async () => {
+ const wrapper = await mountBrowser();
+ expect(wrapper.vm.tabs).toHaveLength(1);
+ expect(wrapper.vm.activeTabId).toBe(wrapper.vm.tabs[0].id);
+ });
+
+ it("addTab creates and activates a new tab with a stable storage id", async () => {
+ const wrapper = await mountBrowser();
+ const before = wrapper.vm.tabs.length;
+ const id = wrapper.vm.addTab();
+ expect(wrapper.vm.tabs).toHaveLength(before + 1);
+ expect(wrapper.vm.activeTabId).toBe(id);
+ expect(wrapper.vm.tabs.find((t) => t.id === id).storageId).toBeTruthy();
+ });
+
+ it("tabTitle uses the saved title and falls back to the new map label", async () => {
+ const wrapper = await mountBrowser();
+ expect(wrapper.vm.tabTitle({ title: "Field AO" })).toBe("Field AO");
+ expect(wrapper.vm.tabTitle({ title: null })).toBe("map.new_tab");
+ });
+
+ it("closeTab activates a neighbour and never leaves zero tabs", async () => {
+ const wrapper = await mountBrowser();
+ const first = wrapper.vm.tabs[0].id;
+ const second = wrapper.vm.addTab("Second map");
+ expect(wrapper.vm.tabs).toHaveLength(2);
+
+ wrapper.vm.closeTab(second);
+ expect(wrapper.vm.tabs).toHaveLength(1);
+ expect(wrapper.vm.activeTabId).toBe(first);
+
+ wrapper.vm.closeTab(first);
+ expect(wrapper.vm.tabs).toHaveLength(1);
+ expect(wrapper.vm.activeTabId).toBe(wrapper.vm.tabs[0].id);
+ });
+
+ it("shows the tab strip when tabs exist", async () => {
+ const wrapper = await mountBrowser();
+ expect(wrapper.find('[role="tablist"]').exists()).toBe(true);
+ });
+
+ it("persists tab layout to localStorage when tabs change", async () => {
+ const wrapper = await mountBrowser();
+ wrapper.vm.addTab("Ops map");
+ await wrapper.vm.$nextTick();
+
+ const saved = JSON.parse(localStorage.getItem("meshchatx.map.tabs"));
+ expect(saved.tabs).toHaveLength(2);
+ expect(saved.tabs[1]).toMatchObject({
+ title: "Ops map",
+ userRenamed: true,
+ });
+ expect(saved.activeIndex).toBe(1);
+ });
+
+ it("restores persisted tabs on mount", async () => {
+ localStorage.setItem(
+ "meshchatx.map.tabs",
+ JSON.stringify({
+ tabs: [
+ { storageId: "tab-a", title: "Alpha", userRenamed: true, tabNumber: 1 },
+ { storageId: "tab-b", title: "Bravo", userRenamed: true, tabNumber: 2 },
+ ],
+ activeIndex: 1,
+ })
+ );
+
+ const wrapper = await mountBrowser();
+ expect(wrapper.vm.tabs).toHaveLength(2);
+ expect(wrapper.vm.tabs[0].storageId).toBe("tab-a");
+ expect(wrapper.vm.activeTabId).toBe(wrapper.vm.tabs[1].id);
+ });
+
+ it("renames a tab on commit and marks it user-renamed", async () => {
+ const wrapper = await mountBrowser();
+ const tab = wrapper.vm.tabs[0];
+ wrapper.vm.startRename(tab.id);
+ wrapper.vm.renameDraft = " Relay site ";
+ wrapper.vm.commitRename();
+
+ expect(tab.title).toBe("Relay site");
+ expect(tab.userRenamed).toBe(true);
+ });
+
+ it("does not overwrite a user-renamed title from map search suggestions", async () => {
+ const wrapper = await mountBrowser();
+ const tab = wrapper.vm.tabs[0];
+ tab.userRenamed = true;
+ tab.title = "My map";
+
+ wrapper.vm.onMapUpdateTitle(tab.id, "San Francisco");
+ expect(tab.title).toBe("My map");
+ });
+
+ it("updates an auto title from map search suggestions", async () => {
+ const wrapper = await mountBrowser();
+ const tab = wrapper.vm.tabs[0];
+
+ wrapper.vm.onMapUpdateTitle(tab.id, "San Francisco");
+ expect(tab.title).toBe("San Francisco");
+ });
+
+ it("migrates legacy last_view state into the first restored tab", async () => {
+ TileCache.getMapState.mockImplementation(async (key) => {
+ if (key === "last_view") {
+ return { center: [1, 2], zoom: 8 };
+ }
+ return null;
+ });
+
+ localStorage.setItem(
+ "meshchatx.map.tabs",
+ JSON.stringify({
+ tabs: [{ storageId: "legacy-tab", title: "Map 1", userRenamed: false, tabNumber: 1 }],
+ activeIndex: 0,
+ })
+ );
+
+ await mountBrowser();
+ await vi.waitFor(() => {
+ expect(TileCache.setMapState).toHaveBeenCalledWith(
+ "map_tab_legacy-tab",
+ expect.objectContaining({ center: [1, 2], zoom: 8 })
+ );
+ });
+ });
+
+ it("renders one embedded MapPage per tab", async () => {
+ const wrapper = await mountBrowser();
+ wrapper.vm.addTab("Second");
+ await wrapper.vm.$nextTick();
+ const pages = wrapper.findAllComponents({ name: "MapPage" });
+ expect(pages).toHaveLength(2);
+ expect(pages[0].props("embedded")).toBe(true);
+ expect(pages[1].props("embedded")).toBe(true);
+ expect(pages[1].props("isActiveTab")).toBe(true);
+ expect(pages[0].props("isActiveTab")).toBe(false);
+ });
+
+ it("Ctrl+T opens a new tab", async () => {
+ const wrapper = await mountBrowser();
+ const before = wrapper.vm.tabs.length;
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "t", ctrlKey: true, bubbles: true, cancelable: true })
+ );
+ expect(wrapper.vm.tabs).toHaveLength(before + 1);
+ });
+});

diff --git a/tests/frontend/MessageBlocklistPage.test.js b/tests/frontend/MessageBlocklistPage.test.js
new file mode 100644
index 00000000..3f2e9774
--- /dev/null
+++ b/tests/frontend/MessageBlocklistPage.test.js
@@ -0,0 +1,139 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import MessageBlocklistPage from "@/components/tools/MessageBlocklistPage.vue";
+import { createRouter, createWebHistory } from "vue-router";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn(() => Promise.resolve(false)),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DownloadUtils", () => ({
+ default: {
+ downloadFile: vi.fn(() => Promise.resolve()),
+ },
+}));
+
+describe("MessageBlocklistPage.vue", () => {
+ const router = createRouter({
+ history: createWebHistory(),
+ routes: [{ path: "/tools", name: "tools", component: { template: "<div/>" } }],
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.api.get = vi.fn((url) => {
+ if (url.includes("message-blocklist/export")) {
+ return Promise.resolve({
+ data: {
+ schema: "meshchatx.message_blocklist",
+ version: 1,
+ entries: [],
+ },
+ });
+ }
+ if (url.includes("message-blocklist")) {
+ return Promise.resolve({
+ data: {
+ enabled: false,
+ blocklist: {
+ scope: "non_contacts",
+ match_peer_fields: false,
+ match_message: true,
+ entries: [{ id: "e1", enabled: true, text: "spam", match_mode: "substring" }],
+ },
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ global.api.put = vi.fn(() =>
+ Promise.resolve({
+ data: {
+ enabled: true,
+ blocklist: {
+ scope: "non_contacts",
+ match_message: true,
+ match_peer_fields: false,
+ entries: [{ id: "e1", enabled: true, text: "spam", match_mode: "substring" }],
+ },
+ },
+ })
+ );
+ global.api.post = vi.fn(() =>
+ Promise.resolve({
+ data: {
+ enabled: false,
+ blocklist: {
+ scope: "non_contacts",
+ match_message: true,
+ match_peer_fields: false,
+ entries: [],
+ },
+ },
+ })
+ );
+ window.api = global.api;
+ });
+
+ it("loads blocklist from the API", async () => {
+ const wrapper = mount(MessageBlocklistPage, {
+ global: {
+ plugins: [router],
+ mocks: { $t: (k) => k },
+ stubs: {
+ MaterialDesignIcon: { template: "<span/>", props: ["iconName"] },
+ ToolsPageHeader: { template: "<div/>" },
+ RouterLink: { template: "<a><slot/></a>", props: ["to"] },
+ },
+ },
+ });
+ await Promise.resolve();
+ await wrapper.vm.$nextTick();
+ await Promise.resolve();
+ expect(global.api.get).toHaveBeenCalledWith("/api/v1/lxmf/message-blocklist");
+ expect(wrapper.vm.blocklist.entries.length).toBe(1);
+ expect(wrapper.vm.enabled).toBe(false);
+ });
+
+ it("saves blocklist via PUT", async () => {
+ const wrapper = mount(MessageBlocklistPage, {
+ global: {
+ plugins: [router],
+ mocks: { $t: (k) => k },
+ stubs: {
+ MaterialDesignIcon: { template: "<span/>", props: ["iconName"] },
+ ToolsPageHeader: { template: "<div/>" },
+ RouterLink: { template: "<a><slot/></a>", props: ["to"] },
+ },
+ },
+ });
+ await Promise.resolve();
+ await wrapper.vm.$nextTick();
+ await Promise.resolve();
+ await wrapper.vm.save();
+ expect(global.api.put).toHaveBeenCalledWith(
+ "/api/v1/lxmf/message-blocklist",
+ expect.objectContaining({
+ enabled: false,
+ blocklist: expect.objectContaining({
+ entries: expect.arrayContaining([
+ expect.objectContaining({ text: "spam", match_mode: "substring" }),
+ ]),
+ }),
+ })
+ );
+ expect(ToastUtils.success).toHaveBeenCalled();
+ });
+});

diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index d2275275..34d75db9 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -412,6 +412,39 @@ describe("NetworkVisualiser.vue", () => {
expect(wrapper.vm.nodes.getIds()).not.toContain("eth_down");
});
+ it("creates visible edges between local node, interfaces, and peers", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ wrapper.vm.network = {
+ getPositions: vi.fn().mockReturnValue({}),
+ setOptions: vi.fn(),
+ redraw: vi.fn(),
+ on: vi.fn(),
+ destroy: vi.fn(),
+ getScale: vi.fn().mockReturnValue(1),
+ };
+ wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
+ wrapper.vm.interfaces = [{ name: "eth0", status: true, bitrate: 1000, txb: 0, rxb: 0 }];
+ wrapper.vm.pathTable = [{ hash: "node1", interface: "eth0", hops: 1 }];
+ wrapper.vm.announces = {
+ node1: {
+ destination_hash: "node1",
+ aspect: "lxmf.delivery",
+ display_name: "Remote",
+ updated_at: new Date().toISOString(),
+ },
+ };
+
+ await wrapper.vm.processVisualization();
+
+ expect(wrapper.vm.edges.getIds()).toContain("me~eth0");
+ expect(wrapper.vm.edges.getIds()).toContain("eth0~node1");
+ for (const edge of wrapper.vm.edges.get()) {
+ expect(edge.hidden).not.toBe(true);
+ }
+ expect(wrapper.vm.network.redraw).toHaveBeenCalled();
+ });
+
it("keeps node positions from getPositions on subsequent layout passes", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
@@ -425,6 +458,7 @@ describe("NetworkVisualiser.vue", () => {
wrapper.vm.network = {
getPositions,
setOptions: vi.fn(),
+ redraw: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
};


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────